Files
CRM_Montzar/apps/web/src/lib/useOrgChat.ts
T

1962 lines
60 KiB
TypeScript

import { computed, ref, shallowRef } from 'vue';
import type { ConversationRoomDto, ConversationSessionDto } from '@mento/shared';
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: 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;
edited?: boolean;
deleted?: boolean;
};
export type RoomLiveEvent = {
roomId: string;
preview: string;
at: string;
fromSelf: boolean;
/** متن خام برای تشخیص منشن در mute هوشمند */
content?: string;
};
export type MessagePinEvent = {
roomId: string;
/** null = unpinned */
messageId: string | null;
preview: string;
fromSelf: boolean;
};
export type MessageReactionEvent = {
roomId: string;
messageId: string;
emoji: string;
added: boolean;
fromSelf: boolean;
};
export type MessageReactionAgg = {
messageId: string;
emoji: string;
count: number;
me: boolean;
};
type RoomListener = (event: RoomLiveEvent) => void;
const roomListeners = new Set<RoomListener>();
export function onRoomLiveEvent(listener: RoomListener) {
roomListeners.add(listener);
return () => roomListeners.delete(listener);
}
function emitRoomLive(event: RoomLiveEvent) {
for (const listener of roomListeners) {
try {
listener(event);
} catch {
// ignore listener errors
}
}
}
type PinListener = (event: MessagePinEvent) => void;
const pinListeners = new Set<PinListener>();
export function onMessagePinEvent(listener: PinListener) {
pinListeners.add(listener);
return () => pinListeners.delete(listener);
}
function emitMessagePin(event: MessagePinEvent) {
for (const listener of pinListeners) {
try {
listener(event);
} catch {
// ignore
}
}
}
type ReactionListener = (event: MessageReactionEvent) => void;
const reactionListeners = new Set<ReactionListener>();
export function onMessageReactionEvent(listener: ReactionListener) {
reactionListeners.add(listener);
return () => reactionListeners.delete(listener);
}
function emitMessageReaction(event: MessageReactionEvent) {
for (const listener of reactionListeners) {
try {
listener(event);
} catch {
// ignore
}
}
}
/** mute map for unread suppression (filled by ConversationsView) */
const mutedRoomPrefs = ref<Record<string, { allowMentions: boolean }>>({});
const mentionSelfName = ref('');
export function setChatMutePrefs(
prefs: Record<string, { allowMentions: boolean }>,
selfFullName = '',
) {
mutedRoomPrefs.value = prefs;
mentionSelfName.value = selfFullName.trim();
}
function shouldCountUnread(roomId: string, content: string, fromSelf: boolean): boolean {
if (fromSelf) return false;
const mute = mutedRoomPrefs.value[roomId];
if (!mute) return true;
if (mute.allowMentions && mentionSelfName.value) {
const needle = `@${mentionSelfName.value}`;
if (content.includes(needle)) return true;
}
return false;
}
const IMAGE_MAX_EDGE = 1600;
const IMAGE_WEBP_QUALITY = 0.82;
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
/** Synced hard-delete marker (legacy edit attempts). */
const HARD_DELETE_MARKER = '__MENTO_DEL__';
/** Realtime hide command delivered as a normal text message (external WS delivers text reliably). */
const HIDE_CMD_PREFIX = '__MENTO_HIDE__:';
/** Realtime pin / unpin control messages. */
const PIN_CMD_PREFIX = '__MENTO_PIN__:';
const UNPIN_CMD = '__MENTO_UNPIN__';
/** Typing fallback when WS `typing` event is not relayed by external chat. */
const TYPING_CMD_PREFIX = '__MENTO_TYPING__:';
/** Reaction sync: __MENTO_REACT__:messageId:emoji:1|0 */
const REACT_CMD_PREFIX = '__MENTO_REACT__:';
function isDeletedContent(content: string): boolean {
const t = content.trim();
return (
/^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(t) ||
t.includes('MENTO_DEL') ||
t === HARD_DELETE_MARKER ||
t.startsWith(HIDE_CMD_PREFIX) ||
t.startsWith(PIN_CMD_PREFIX) ||
t === UNPIN_CMD ||
t.startsWith(TYPING_CMD_PREFIX) ||
t.startsWith(REACT_CMD_PREFIX)
);
}
function parseHideCommand(content: string): string | null {
const t = content.trim();
if (!t.startsWith(HIDE_CMD_PREFIX)) return null;
const id = t.slice(HIDE_CMD_PREFIX.length).trim();
return id || null;
}
/** null = not a pin cmd; '' = unpin; otherwise messageId */
function parsePinCommand(content: string): string | null {
const t = content.trim();
if (t === UNPIN_CMD) return '';
if (!t.startsWith(PIN_CMD_PREFIX)) return null;
const id = t.slice(PIN_CMD_PREFIX.length).trim();
return id || null;
}
/** null = not react; otherwise { messageId, emoji, added } */
function parseReactCommand(
content: string,
): { messageId: string; emoji: string; added: boolean } | null {
const t = content.trim();
if (!t.startsWith(REACT_CMD_PREFIX)) return null;
const parts = t.slice(REACT_CMD_PREFIX.length).split(':');
if (parts.length < 3) return null;
const messageId = parts[0]?.trim();
const emoji = parts[1]?.trim();
const flag = parts[2]?.trim();
if (!messageId || !emoji) return null;
return { messageId, emoji, added: flag !== '0' };
}
function purgeMessageById(id: string) {
const key = String(id || '').trim();
if (!key) return;
const next = messages.value.filter((m) => m.id !== key);
if (next.length !== messages.value.length) messages.value = next;
}
function purgeMessageIds(ids: Iterable<string>) {
const set = new Set([...ids].map((id) => String(id || '').trim()).filter(Boolean));
if (!set.size) return;
const next = messages.value.filter((m) => !set.has(m.id));
if (next.length !== messages.value.length) messages.value = next;
}
const hiddenIdsByRoom = new Map<string, Set<string>>();
let hiddenPollTimer: ReturnType<typeof setInterval> | null = null;
function rememberHidden(roomId: string, ids: string[]) {
if (!roomId || !ids.length) return;
let set = hiddenIdsByRoom.get(roomId);
if (!set) {
set = new Set();
hiddenIdsByRoom.set(roomId, set);
}
for (const id of ids) {
const key = String(id || '').trim();
if (key) set.add(key);
}
}
function isRememberedHidden(roomId: string, messageId: string): boolean {
return Boolean(hiddenIdsByRoom.get(roomId)?.has(messageId));
}
async function persistHiddenOnServer(
externalRoomId: string,
messageIds: string[],
forEveryone = true,
) {
const ids = [...new Set(messageIds.map((id) => String(id || '').trim()).filter(Boolean))];
if (!externalRoomId || !ids.length) return ids;
rememberHidden(externalRoomId, ids);
purgeMessageIds(ids);
try {
await api.post('/conversations/messages/hide', {
externalRoomId,
messageIds: ids,
forEveryone,
});
} catch {
// local purge already applied; sync may retry via poll
}
return ids;
}
async function fetchHiddenFromServer(externalRoomId: string): Promise<string[]> {
if (!externalRoomId) return [];
try {
const { data } = await api.get<{ messageIds: string[] }>('/conversations/messages/hidden', {
params: { externalRoomId },
});
const ids = Array.isArray(data?.messageIds) ? data.messageIds : [];
rememberHidden(externalRoomId, ids);
return ids;
} catch {
return [...(hiddenIdsByRoom.get(externalRoomId) ?? [])];
}
}
function stopHiddenPoll() {
if (hiddenPollTimer) {
clearInterval(hiddenPollTimer);
hiddenPollTimer = null;
}
}
function startHiddenPoll(roomId: string) {
stopHiddenPoll();
if (!roomId) return;
hiddenPollTimer = setInterval(() => {
void (async () => {
const ids = await fetchHiddenFromServer(roomId);
if (ids.length) purgeMessageIds(ids);
})();
}, 3000);
}
let socket: WebSocket | null = null;
let sessionCache: ConversationSessionDto | null = null;
let connectPromise: Promise<void> | null = null;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let wantSocket = false;
const status = ref<'idle' | 'connecting' | 'open' | 'closed'>('idle');
const messages = shallowRef<ChatMessage[]>([]);
const activeRoomId = ref('');
const unreadTotal = ref(0);
const pageOpen = ref(false);
const hasMoreMessages = ref(false);
const loadingOlderMessages = ref(false);
/** تعداد خام پیام‌های گرفته‌شده از API (برای offset) */
let fetchedRawCount = 0;
const typingMap = ref<
Record<string, Array<{ userId: string; name?: string; at: number }>>
>({});
let typingSweepTimer: ReturnType<typeof setInterval> | null = null;
let typingPollTimer: ReturnType<typeof setInterval> | null = null;
function startTypingSweep() {
if (typingSweepTimer) return;
typingSweepTimer = setInterval(() => {
const now = Date.now();
let changed = false;
const next: Record<string, Array<{ userId: string; name?: string; at: number }>> = {};
for (const [roomId, list] of Object.entries(typingMap.value)) {
const kept = list.filter((t) => now - t.at < 4000);
if (kept.length) next[roomId] = kept;
if (kept.length !== list.length) changed = true;
}
if (changed) typingMap.value = next;
}, 1000);
}
function applyTypingEvent(roomId: string, userId: string, name?: string) {
const rid = String(roomId || activeRoomId.value || '').trim();
const uid = String(userId || '').trim() || '__someone__';
if (!rid) return;
if (sessionCache?.chatUserId && uid === sessionCache.chatUserId) return;
startTypingSweep();
const list = [...(typingMap.value[rid] || [])].filter((t) => t.userId !== uid);
list.push({ userId: uid, name: name || undefined, at: Date.now() });
typingMap.value = { ...typingMap.value, [rid]: list };
}
function applyTypingFromServerAll(
rows: Array<{
externalRoomId: string;
userId: string;
fullName: string;
chatUserId: string;
}>,
) {
const now = Date.now();
const next: Record<string, Array<{ userId: string; name?: string; at: number }>> = {};
for (const row of rows) {
const rid = String(row.externalRoomId || '').trim();
const uid = String(row.chatUserId || row.userId || '').trim();
if (!rid || !uid || uid === sessionCache?.chatUserId) continue;
const list = next[rid] || [];
if (!list.some((t) => t.userId === uid)) {
list.push({ userId: uid, name: row.fullName || undefined, at: now });
}
next[rid] = list;
}
typingMap.value = next;
if (Object.keys(next).length) startTypingSweep();
}
function parseTypingCommand(content: string): string | null {
const t = content.trim();
if (!t.startsWith(TYPING_CMD_PREFIX)) return null;
return t.slice(TYPING_CMD_PREFIX.length).trim() || '__someone__';
}
function clearTypingUser(roomId: string, userId: string) {
const rid = String(roomId || '').trim();
const uid = String(userId || '').trim();
if (!rid || !uid || !typingMap.value[rid]) return;
const kept = typingMap.value[rid].filter((t) => t.userId !== uid);
const next = { ...typingMap.value };
if (kept.length) next[rid] = kept;
else delete next[rid];
typingMap.value = next;
}
function stopTypingPoll() {
if (typingPollTimer) {
clearInterval(typingPollTimer);
typingPollTimer = null;
}
}
/** poll همه اتاق‌ها تا پیش‌نمایش لیست هم «در حال نوشتن» بگیرد */
function startTypingPoll() {
if (typingPollTimer) return;
const tick = async () => {
if (!pageOpen.value) return;
try {
const { data } = await api.get<
Array<{
externalRoomId: string;
userId: string;
fullName: string;
chatUserId: string;
}>
>('/conversations/rooms/typing');
applyTypingFromServerAll(Array.isArray(data) ? data : []);
} catch {
// ignore
}
};
void tick();
typingPollTimer = setInterval(() => {
void tick();
}, 1400);
}
const PAGE_SIZE = 40;
function messageTs(item: ChatMessage): number {
const t = new Date(item.createdAt).getTime();
return Number.isNaN(t) ? 0 : t;
}
function sortAsc(items: ChatMessage[]): ChatMessage[] {
return [...items].sort((a, b) => {
const d = messageTs(a) - messageTs(b);
if (d !== 0) return d;
return a.id.localeCompare(b.id);
});
}
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';
}
const VIDEO_EXT_RE = /\.(mp4|mov|m4v|mkv|avi|3gp|ogv)(?:\?|$)/i;
const AUDIO_EXT_RE = /\.(webm|ogg|mp3|m4a|wav|aac|opus|oga)(?:\?|$)/i;
const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|bmp|svg|heic|heif)(?:\?|$)/i;
const VOICE_NAME_RE = /(?:^|[\\/._-])voice(?:[\\/._-]|$)/i;
/** Uploaded chat videos are camouflaged as .jpg so the external WS accepts them as image. */
const VIDEO_CAMO_RE = /(?:^|[\\/._-])vid-\d+/i;
function looksLikeVideo(pathOrName: string): boolean {
return VIDEO_EXT_RE.test(pathOrName);
}
function looksLikeVoiceAsset(pathOrName: string): boolean {
return VOICE_NAME_RE.test(pathOrName) || AUDIO_EXT_RE.test(pathOrName);
}
function looksLikeCamouflagedVideo(pathOrName: string): boolean {
return VIDEO_CAMO_RE.test(pathOrName);
}
function looksLikeVideoAsset(pathOrName: string): boolean {
return looksLikeVideo(pathOrName) || looksLikeCamouflagedVideo(pathOrName);
}
function inferMediaType(
declared: ChatMediaType,
content: string,
fileName?: string,
): ChatMediaType {
const probe = `${fileName || ''} ${content}`;
// Voice recordings must stay audio UI (often .webm). Camouflaged videos never use voice names.
if (declared === 'voice' && !looksLikeVideoAsset(probe)) return 'voice';
if (looksLikeVoiceAsset(probe) && !looksLikeVideoAsset(probe)) return 'voice';
if (looksLikeVideoAsset(probe)) return 'video';
if (declared === 'video') return 'video';
if (declared === 'image') return 'image';
if (declared === 'file' || declared === 'text') {
if (IMAGE_EXT_RE.test(probe)) return 'image';
if (declared === 'file') return 'file';
}
return declared;
}
export function previewLabelForMessage(msg: Pick<ChatMessage, 'type' | 'content' | 'fileName'>) {
if (msg.type === 'image') return 'تصویر';
if (msg.type === 'voice') return 'پیام صوتی';
if (msg.type === 'video') return 'ویدیو';
if (msg.type === 'file') {
if (looksLikeVideoAsset(`${msg.fileName || ''} ${msg.content}`)) return 'ویدیو';
if (looksLikeVoiceAsset(`${msg.fileName || ''} ${msg.content}`)) return 'پیام صوتی';
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();
}
/** Classify upload/attach files for chat UI + WS. Exported for composer pending previews. */
export function classifyChatFile(file: File): Exclude<ChatMediaType, 'text'> {
const mime = (file.type || '').toLowerCase();
const name = file.name || '';
if (looksLikeVoiceAsset(name) || mime.startsWith('audio/')) return 'voice';
if (mime.startsWith('image/') || IMAGE_EXT_RE.test(name)) return 'image';
// Audio-only MediaRecorder often reports video/webm / video/ogg
if (
mime.startsWith('video/webm') ||
mime.startsWith('video/ogg') ||
(mime.startsWith('video/') && AUDIO_EXT_RE.test(name) && !looksLikeVideo(name))
) {
return 'voice';
}
if (mime.startsWith('video/') || looksLikeVideo(name)) return 'video';
if (AUDIO_EXT_RE.test(name)) return 'voice';
return 'file';
}
function classifyFile(file: File): Exclude<ChatMediaType, 'text'> {
return classifyChatFile(file);
}
/**
* External chat WS reliably delivers text|image|voice.
* Native `video` is dropped; `file` with .mp4 is also dropped.
* Videos are uploaded as image-looking paths (vid-*.jpg) and sent as type image.
*/
function wsMediaType(type: ChatMediaType): 'image' | 'voice' | 'file' | 'text' {
if (type === 'image' || type === 'video') return 'image';
if (type === 'voice') return 'voice';
if (type === 'file') return 'file';
return 'text';
}
/**
* پسوندهایی که سرویس آپلود montifyshop (مثلاً /upload/crm-montact) می‌پذیرد.
* بقیه باید تبدیل یا camouflage شوند وگرنه ۴۰۰ Invalid file type می‌دهد.
*/
const UPLOAD_DIRECT_EXT = new Set([
'jpg',
'jpeg',
'png',
'webp',
'pdf',
'mp3',
'mp4',
'webm',
'ogg',
'm4a',
]);
function fileExt(name: string): string {
const m = /\.([a-z0-9]+)$/i.exec(String(name || '').trim());
return m?.[1]?.toLowerCase() ?? '';
}
function canUploadDirectly(file: File): boolean {
const ext = fileExt(file.name);
if (ext && UPLOAD_DIRECT_EXT.has(ext)) return true;
const mime = (file.type || '').toLowerCase().split(';')[0];
return (
mime === 'image/jpeg' ||
mime === 'image/png' ||
mime === 'image/webp' ||
mime === 'application/pdf' ||
mime === 'audio/mpeg' ||
mime === 'audio/webm' ||
mime === 'audio/ogg' ||
mime === 'audio/mp4' ||
mime === 'video/mp4' ||
mime === 'video/webm' ||
mime === 'video/ogg'
);
}
async function rasterizeImageToJpeg(file: File): Promise<File | null> {
try {
const bitmap = await createImageBitmap(file);
const ratio = Math.min(
IMAGE_MAX_EDGE / bitmap.width,
IMAGE_MAX_EDGE / bitmap.height,
1,
);
const width = Math.max(1, Math.round(bitmap.width * ratio));
const height = Math.max(1, 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 null;
}
ctx.drawImage(bitmap, 0, 0, width, height);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob((b) => resolve(b), 'image/jpeg', 0.9);
});
if (!blob) return null;
const base = file.name.replace(/\.[^/.]+$/, '') || 'image';
return new File([blob], `${base}.jpg`, {
type: 'image/jpeg',
lastModified: Date.now(),
});
} catch {
return null;
}
}
/**
* سرویس آپلود whitelist سختگیرانه دارد (مثلاً docx/txt/gif/heic → 400).
* تصویرهای ناسازگار را به JPEG تبدیل می‌کنیم؛ بقیه را مثل ویدیو camouflage می‌کنیم
* (آپلود به‌صورت .pdf) و نام اصلی را برای UI/دانلود نگه می‌داریم.
*/
async function prepareFileForUploadService(
file: File,
mediaType: Exclude<ChatMediaType, 'text'>,
): Promise<{ uploadFile: File; displayName: string }> {
const displayName = file.name?.trim() || `file-${Date.now()}`;
const mime = (file.type || '').toLowerCase().split(';')[0];
const ext = fileExt(displayName);
if (mediaType === 'voice') {
if (canUploadDirectly(file)) return { uploadFile: file, displayName };
throw new Error(
'این فرمت صوت پشتیبانی نمی‌شود. webm، mp3، ogg یا m4a بفرستید.',
);
}
if (mediaType === 'image') {
const needsRaster =
mime === 'image/gif' ||
mime === 'image/bmp' ||
mime === 'image/heic' ||
mime === 'image/heif' ||
mime === 'image/svg+xml' ||
['gif', 'bmp', 'heic', 'heif', 'svg', 'tif', 'tiff'].includes(ext);
if (needsRaster || !canUploadDirectly(file)) {
const converted = await rasterizeImageToJpeg(file);
if (converted) return { uploadFile: converted, displayName: converted.name };
}
if (canUploadDirectly(file)) return { uploadFile: file, displayName };
}
if (canUploadDirectly(file)) {
return { uploadFile: file, displayName };
}
// اسناد/آرشیو و… → آپلود با پسوند pdf (whitelist سرویس)؛ نام اصلی برای دانلود می‌ماند
const uploadFile = new File([file], `doc-${Date.now()}.pdf`, {
type: 'application/pdf',
lastModified: file.lastModified,
});
return { uploadFile, displayName };
}
async function compressImageIfNeeded(file: File): Promise<File> {
if (!file.type.startsWith('image/')) return file;
// gif/svg/heic را prepareFileForUploadService قبلاً به JPEG تبدیل کرده
if (
file.type === 'image/gif' ||
file.type === 'image/svg+xml' ||
file.type === 'image/heic' ||
file.type === 'image/heif'
) {
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,
): ChatMessage | null {
const base =
raw.message && typeof raw.message === 'object'
? (raw.message as Record<string, unknown>)
: raw;
const declared = normalizeMediaType(String(base.type_content ?? base.type ?? 'text'));
const content = String(
base.content ??
base.text ??
base.body ??
base.file_url ??
base.fileUrl ??
base.url ??
base.path ??
'',
).trim();
if (!content && !base.attachments) return null;
const id = String(base.id ?? base.message_id ?? '').trim();
const readAt = base.read_at ?? base.readAt ?? null;
const downloadUrl = sessionCache?.downloadUrl ?? '';
const fileName =
String(base.file_name ?? base.fileName ?? base.name ?? '').trim() ||
content.split('/').pop() ||
undefined;
const roomId = String(base.room_id ?? raw.room_id ?? roomFallback);
const type = inferMediaType(declared, content, fileName);
const isMedia = type !== 'text';
const mediaUrl = isMedia ? buildChatFileUrl(content, downloadUrl) : undefined;
const deletedFlag = Boolean(base.deleted || base.is_deleted || base.isDeleted);
const hideTarget = parseHideCommand(content);
if (
deletedFlag ||
isDeletedContent(content) ||
hideTarget ||
(id && isRememberedHidden(roomId, id))
) {
return null;
}
const edited = Boolean(base.edited || base.is_edited || base.isEdited || base.edited_at);
return {
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
roomId,
senderId: String(
base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '',
),
content,
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,
edited,
deleted: false,
};
}
function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[] {
if (incoming.deleted || isDeletedContent(incoming.content)) {
return list.filter((m) => m.id !== incoming.id);
}
const byId = list.findIndex((m) => m.id === incoming.id);
if (byId >= 0) {
const prev = list[byId];
if (prev.deleted) {
return list.filter((m) => m.id !== prev.id);
}
const next = [...list];
next[byId] = {
...prev,
...incoming,
optimistic: false,
type: inferMediaType(
prev.type === 'voice' || incoming.type === 'voice'
? 'voice'
: prev.type === 'video' || incoming.type === 'video'
? 'video'
: incoming.type,
incoming.content || prev.content,
incoming.fileName || prev.fileName,
),
localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl,
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
fileName: incoming.fileName || prev.fileName,
edited: incoming.edited || prev.edited,
deleted: false,
delivery:
incoming.delivery === 'read' || prev.delivery === 'read'
? 'read'
: incoming.delivery || prev.delivery || 'sent',
readAt: incoming.readAt ?? prev.readAt,
};
return sortAsc(next);
}
const optimisticIdx = list.findIndex(
(m) =>
m.optimistic &&
m.roomId === incoming.roomId &&
m.senderId === incoming.senderId &&
(m.content === incoming.content ||
(m.type === incoming.type &&
m.type !== 'text' &&
Boolean(m.localPreviewUrl))),
);
if (optimisticIdx >= 0) {
const prev = list[optimisticIdx];
const next = [...list];
next[optimisticIdx] = {
...prev,
...incoming,
optimistic: false,
type: inferMediaType(
prev.type === 'voice' || incoming.type === 'voice'
? 'voice'
: prev.type === 'video' || incoming.type === 'video'
? 'video'
: incoming.type,
incoming.content || prev.content,
incoming.fileName || prev.fileName,
),
localPreviewUrl: prev.localPreviewUrl,
uploadProgress: undefined,
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
fileName: incoming.fileName || prev.fileName,
edited: incoming.edited || prev.edited,
deleted: incoming.deleted || prev.deleted,
delivery: incoming.readAt ? 'read' : 'sent',
};
if (prev.localPreviewUrl?.startsWith('blob:')) {
try {
URL.revokeObjectURL(prev.localPreviewUrl);
} catch {
// ignore
}
}
return sortAsc(next);
}
return sortAsc([...list, incoming]);
}
function applySeenPayload(payload: Record<string, unknown>) {
const roomId = String(payload.room_id ?? '');
if (!roomId || roomId !== activeRoomId.value) return;
const myId = sessionCache?.chatUserId ?? '';
const readerId = String(payload.reader_id ?? '');
if (!readerId || readerId === myId) return;
const ids = new Set(
Array.isArray(payload.seen_message_ids)
? payload.seen_message_ids.map((id) => String(id))
: [],
);
if (!ids.size) {
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()) }
: m,
);
return;
}
const seenAt = String(payload.seen_at ?? new Date().toISOString());
messages.value = messages.value.map((m) => {
if (!ids.has(m.id)) return m;
if (m.senderId !== myId) return m;
return { ...m, delivery: 'read' as const, readAt: seenAt };
});
}
async function ensureSession(): Promise<ConversationSessionDto> {
if (sessionCache) return sessionCache;
const { data } = await api.get<ConversationSessionDto>('/conversations/session');
sessionCache = data;
return data;
}
function clearPing() {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
}
function clearReconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
function scheduleReconnect() {
if (!wantSocket || reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
void connectSocket().catch(() => {
scheduleReconnect();
});
}, 2500);
}
function disconnectSocket() {
clearPing();
clearReconnect();
if (socket) {
socket.onmessage = null;
socket.onclose = null;
socket.onerror = null;
socket.close();
socket = null;
}
status.value = 'closed';
connectPromise = null;
}
function handleIncomingMessage(msg: ChatMessage) {
const myId = sessionCache?.chatUserId ?? '';
const fromSelf = Boolean(myId && msg.senderId === myId);
if (msg.senderId) clearTypingUser(msg.roomId, msg.senderId);
if (msg.roomId === activeRoomId.value) {
messages.value = mergeMessages(
{
...msg,
delivery: fromSelf ? msg.delivery || 'sent' : msg.delivery,
},
messages.value,
);
if (!fromSelf && pageOpen.value) {
sendRead(msg.roomId);
}
} else if (!fromSelf) {
if (shouldCountUnread(msg.roomId, msg.content || '', fromSelf)) {
unreadTotal.value += 1;
}
}
emitRoomLive({
roomId: msg.roomId,
preview: previewLabelForMessage(msg),
at: msg.createdAt,
fromSelf,
content: msg.type === 'text' ? msg.content : '',
});
}
function applyEditedMessage(payload: Record<string, unknown>) {
const base =
payload.message && typeof payload.message === 'object'
? (payload.message as Record<string, unknown>)
: payload;
const id = String(base.id ?? base.message_id ?? payload.message_id ?? '').trim();
if (!id) return;
const content = String(base.content ?? base.text ?? base.body ?? '').trim();
if (!content) return;
// Hard-delete sync uses edit channel with purge marker
if (isDeletedContent(content)) {
purgeMessageById(id);
return;
}
messages.value = messages.value.map((m) =>
m.id === id
? {
...m,
content,
type: 'text',
edited: true,
deleted: false,
mediaUrl: undefined,
fileName: undefined,
}
: m,
);
}
function applyDeletedMessage(payload: Record<string, unknown>) {
const base =
payload.message && typeof payload.message === 'object'
? (payload.message as Record<string, unknown>)
: payload;
const id = String(base.id ?? base.message_id ?? payload.message_id ?? '').trim();
purgeMessageById(id);
}
function extractPayloadContent(parsed: Record<string, unknown>): string {
const base =
parsed.message && typeof parsed.message === 'object'
? (parsed.message as Record<string, unknown>)
: parsed;
return String(
base.content ?? base.text ?? base.body ?? parsed.content ?? '',
).trim();
}
function extractPayloadId(parsed: Record<string, unknown>): string {
const base =
parsed.message && typeof parsed.message === 'object'
? (parsed.message as Record<string, unknown>)
: parsed;
return String(base.id ?? base.message_id ?? parsed.message_id ?? '').trim();
}
function onSocketMessage(event: MessageEvent) {
const raw = String(event.data ?? '').trim();
if (!raw || raw === 'pong' || raw === 'ping') return;
try {
const parsed = JSON.parse(raw) as Record<string, unknown>;
const type = String(parsed.type ?? parsed.event ?? '').toLowerCase();
if (type === 'pong' || type === 'ping' || type === 'read') return;
if (type === 'typing') {
const roomId = String(
parsed.room_id ??
parsed.roomId ??
((parsed.message as Record<string, unknown> | undefined)?.room_id as string) ??
activeRoomId.value,
);
const userId = String(
parsed.user_id ??
parsed.sender_id ??
parsed.from_user_id ??
parsed.userId ??
((parsed.message as Record<string, unknown> | undefined)?.sender_id as string) ??
'',
);
applyTypingEvent(roomId, userId);
return;
}
if (type === 'seen') {
applySeenPayload(parsed);
return;
}
if (
type === 'edit' ||
type === 'edited' ||
type === 'update' ||
type === 'message_edit' ||
type === 'message:edit' ||
type === 'message_edited'
) {
applyEditedMessage(parsed);
return;
}
if (
type === 'delete' ||
type === 'deleted' ||
type === 'remove' ||
type === 'message_delete' ||
type === 'message:delete' ||
type === 'message_deleted'
) {
applyDeletedMessage(parsed);
return;
}
const purgeId = extractPayloadId(parsed);
const purgeContent = extractPayloadContent(parsed);
const hideTarget = parseHideCommand(purgeContent);
if (hideTarget) {
const roomId = String(
(parsed.room_id as string) ||
((parsed.message as Record<string, unknown> | undefined)?.room_id as string) ||
activeRoomId.value,
);
const ids = [hideTarget, purgeId].filter(Boolean);
purgeMessageIds(ids);
rememberHidden(roomId, ids);
void persistHiddenOnServer(roomId, ids, true);
return;
}
const typingFromCmd = parseTypingCommand(purgeContent);
if (typingFromCmd) {
const nested =
parsed.message && typeof parsed.message === 'object'
? (parsed.message as Record<string, unknown>)
: undefined;
const roomId = String(
(parsed.room_id as string) ||
(parsed.roomId as string) ||
(nested?.room_id as string) ||
activeRoomId.value,
);
const sender =
typingFromCmd !== '__someone__'
? typingFromCmd
: String(
(parsed as { sender_id?: string }).sender_id ||
(parsed as { user_id?: string }).user_id ||
(nested?.sender_id as string) ||
(nested?.user_id as string) ||
'',
);
if (purgeId) {
rememberHidden(roomId, [purgeId]);
void persistHiddenOnServer(roomId, [purgeId], true);
}
applyTypingEvent(roomId, sender);
return;
}
const pinTarget = parsePinCommand(purgeContent);
if (pinTarget !== null) {
const roomId = String(
(parsed.room_id as string) ||
((parsed.message as Record<string, unknown> | undefined)?.room_id as string) ||
activeRoomId.value,
);
if (purgeId) rememberHidden(roomId, [purgeId]);
if (pinTarget === '') {
emitMessagePin({ roomId, messageId: null, preview: '', fromSelf: false });
} else {
const found = messages.value.find((m) => m.id === pinTarget);
emitMessagePin({
roomId,
messageId: pinTarget,
preview: found ? previewLabelForMessage(found) : '',
fromSelf: false,
});
}
return;
}
const reactCmd = parseReactCommand(purgeContent);
if (reactCmd) {
const roomId = String(
(parsed.room_id as string) ||
((parsed.message as Record<string, unknown> | undefined)?.room_id as string) ||
activeRoomId.value,
);
if (purgeId) {
rememberHidden(roomId, [purgeId]);
void persistHiddenOnServer(roomId, [purgeId], true);
}
emitMessageReaction({
roomId,
messageId: reactCmd.messageId,
emoji: reactCmd.emoji,
added: reactCmd.added,
fromSelf: false,
});
return;
}
if (purgeId && isDeletedContent(purgeContent)) {
purgeMessageById(purgeId);
return;
}
const msg = normalizeIncoming(parsed, activeRoomId.value);
if (!msg) return;
if (isRememberedHidden(msg.roomId, msg.id)) return;
handleIncomingMessage(msg);
} catch {
// ignore
}
}
async function connectSocket() {
wantSocket = true;
if (socket?.readyState === WebSocket.OPEN) return;
if (connectPromise) return connectPromise;
connectPromise = (async () => {
const session = await ensureSession();
if (socket?.readyState === WebSocket.OPEN) return;
disconnectSocket();
wantSocket = true;
status.value = 'connecting';
socket = new WebSocket(session.wsUrl);
await new Promise<void>((resolve, reject) => {
if (!socket) return reject(new Error('socket missing'));
const t = setTimeout(() => reject(new Error('ws timeout')), 12000);
socket.addEventListener(
'open',
() => {
clearTimeout(t);
status.value = 'open';
pingTimer = setInterval(() => {
if (socket?.readyState === WebSocket.OPEN) socket.send('ping');
}, 15000);
resolve();
},
{ once: true },
);
socket.addEventListener(
'error',
() => {
clearTimeout(t);
reject(new Error('ws error'));
},
{ once: true },
);
});
if (!socket) return;
socket.addEventListener('message', onSocketMessage);
socket.addEventListener('close', () => {
status.value = 'closed';
clearPing();
socket = null;
connectPromise = null;
if (wantSocket) scheduleReconnect();
});
})().finally(() => {
connectPromise = null;
});
return connectPromise;
}
function sendRead(roomId: string) {
if (!socket || socket.readyState !== WebSocket.OPEN || !roomId) return;
socket.send(JSON.stringify({ type: 'read', room_id: roomId }));
}
function formatUploadFailure(status: number, bodyText: string): string {
const raw = bodyText.trim();
if (raw) {
try {
const json = JSON.parse(raw) as Record<string, unknown>;
const err = String(json.error ?? json.message ?? '').trim();
const details = String(json.details ?? '').trim();
if (/invalid file type/i.test(err)) {
return 'این نوع فایل توسط سرویس آپلود رد شد. تصویر، PDF، صوت یا ویدیو را دوباره امتحان کنید.';
}
if (err && details) return `آپلود ناموفق بود: ${err} (${details})`;
if (err) return `آپلود ناموفق بود: ${err}`;
} catch {
const snippet = raw.slice(0, 160);
if (snippet) return `آپلود ناموفق بود (${status}): ${snippet}`;
}
}
if (status === 400) {
return 'آپلود رد شد (۴۰۰). نوع یا نام فایل با سرویس آپلود سازگار نیست.';
}
if (status === 500) {
return 'آپلود ناموفق بود (۵۰۰). سرویس ذخیره‌سازی فایل (S3) روی آدرس آپلود در دسترس نیست — تنظیمات upload/download در سوپرادمین را بررسی کنید.';
}
return `آپلود ناموفق بود (${status})`;
}
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(formatUploadFailure(xhr.status, xhr.responseText || '')));
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');
unreadTotal.value = data.reduce((sum, room) => sum + (room.unreadCount || 0), 0);
return unreadTotal.value;
} catch {
return unreadTotal.value;
}
}
export function useOrgChat() {
const loadingMessages = ref(false);
async function ensureConnected() {
await connectSocket();
}
function setConversationsPageOpen(open: boolean) {
pageOpen.value = open;
if (open) startTypingPoll();
else {
stopTypingPoll();
typingMap.value = {};
}
}
function normalizeMessageList(
list: Record<string, unknown>[],
roomId: string,
hiddenSet: Set<string>,
myId: string,
): ChatMessage[] {
for (const item of list) {
const rawContent = String(item.content ?? item.text ?? item.body ?? '').trim();
const target = parseHideCommand(rawContent);
if (target) hiddenSet.add(target);
}
rememberHidden(roomId, [...hiddenSet]);
const normalized: ChatMessage[] = [];
for (const item of list) {
const m = normalizeIncoming(item, roomId);
if (!m || hiddenSet.has(m.id)) continue;
normalized.push(
m.senderId === myId
? { ...m, delivery: (m.readAt ? 'read' : 'sent') as ChatDelivery }
: m,
);
}
return sortAsc(normalized);
}
async function loadMessages(roomId: string) {
activeRoomId.value = roomId;
loadingMessages.value = true;
loadingOlderMessages.value = false;
fetchedRawCount = 0;
hasMoreMessages.value = false;
startHiddenPoll(roomId);
startTypingPoll();
try {
const session = await ensureSession();
await connectSocket();
const hiddenIds = await fetchHiddenFromServer(roomId);
const hiddenSet = new Set(hiddenIds);
const params = new URLSearchParams({
api_key: new URL(session.wsUrl).searchParams.get('api_key') ?? '',
room_id: roomId,
user_id: session.chatUserId,
limit: String(PAGE_SIZE),
offset: '0',
});
const url = `${session.messagesBaseUrl}/api/messages?${params.toString()}`;
const res = await fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!res.ok) throw new Error('messages failed');
const body = await res.json();
const list = Array.isArray(body) ? body : body?.data ?? [];
const rows = list as Record<string, unknown>[];
fetchedRawCount = rows.length;
hasMoreMessages.value = rows.length >= PAGE_SIZE;
messages.value = normalizeMessageList(rows, roomId, hiddenSet, session.chatUserId);
sendRead(roomId);
} finally {
loadingMessages.value = false;
}
}
async function loadOlderMessages(roomId: string): Promise<boolean> {
if (!roomId || loadingOlderMessages.value || loadingMessages.value) return false;
if (!hasMoreMessages.value) return false;
loadingOlderMessages.value = true;
try {
const session = await ensureSession();
const hiddenIds = await fetchHiddenFromServer(roomId);
const hiddenSet = new Set(hiddenIds);
const params = new URLSearchParams({
api_key: new URL(session.wsUrl).searchParams.get('api_key') ?? '',
room_id: roomId,
user_id: session.chatUserId,
limit: String(PAGE_SIZE),
offset: String(fetchedRawCount),
});
const url = `${session.messagesBaseUrl}/api/messages?${params.toString()}`;
const res = await fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!res.ok) throw new Error('older messages failed');
const body = await res.json();
const list = Array.isArray(body) ? body : body?.data ?? [];
const rows = list as Record<string, unknown>[];
fetchedRawCount += rows.length;
hasMoreMessages.value = rows.length >= PAGE_SIZE;
if (!rows.length) return false;
const older = normalizeMessageList(rows, roomId, hiddenSet, session.chatUserId);
const existingIds = new Set(messages.value.map((m) => m.id));
const fresh = older.filter((m) => !existingIds.has(m.id));
messages.value = sortAsc([...fresh, ...messages.value]);
return true;
} finally {
loadingOlderMessages.value = false;
}
}
let lastTypingSentAt = 0;
async function sendTyping(roomId: string) {
const rid = String(roomId || '').trim();
if (!rid) return;
const now = Date.now();
if (now - lastTypingSentAt < 1100) return;
lastTypingSentAt = now;
// مسیر اصلی: CRM presence (سرویس خارجی typing را relay نمی‌کند)
try {
await api.post('/conversations/rooms/typing', { externalRoomId: rid });
} catch {
// ignore
}
// best-effort native WS typing
try {
await connectSocket();
if (socket && socket.readyState === WebSocket.OPEN) {
const session = await ensureSession();
socket.send(
JSON.stringify({
type: 'typing',
room_id: rid,
user_id: session.chatUserId,
sender_id: session.chatUserId,
}),
);
}
} catch {
// ignore
}
}
function typingUserIdsForRoom(roomId: string): string[] {
const list = typingMap.value[roomId] || [];
const now = Date.now();
return list.filter((t) => now - t.at < 3500).map((t) => t.userId);
}
async function sendText(roomId: string, text: string, reply: string | null = null) {
const trimmed = text.trim();
if (!trimmed) return;
await connectSocket();
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open');
const session = await ensureSession();
const id = crypto.randomUUID();
const payload: Record<string, string> = {
id,
type: 'text',
room_id: roomId,
content: trimmed,
};
if (reply) payload.reply = reply;
const optimistic: ChatMessage = {
id,
roomId,
senderId: session.chatUserId,
content: trimmed,
type: 'text',
createdAt: new Date().toISOString(),
reply,
optimistic: true,
delivery: 'sending',
};
messages.value = mergeMessages(optimistic, messages.value);
emitRoomLive({
roomId,
preview: trimmed,
at: optimistic.createdAt,
fromSelf: true,
});
socket.send(JSON.stringify(payload));
messages.value = messages.value.map((m) =>
m.id === id ? { ...m, optimistic: false, delivery: 'sent' as const } : m,
);
}
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);
// Never put real video extensions in WS content — external chat drops those messages.
let filePathHint = filePath;
if (type === 'voice' && !/\.[a-z0-9]{2,5}(?:\?|$)/i.test(filePathHint)) {
const ext = opts?.fileName?.match(/(\.[a-z0-9]{2,5})$/i)?.[1];
if (ext) filePathHint = `${filePathHint}${ext}`;
}
const displayName =
type === 'video'
? opts?.fileName && looksLikeVideo(opts.fileName)
? opts.fileName
: opts?.fileName
? opts.fileName.replace(/\.[^.]+$/, '') + '.mp4'
: 'video.mp4'
: opts?.fileName;
const mediaUrl = buildChatFileUrl(filePathHint, session.downloadUrl);
const payload: Record<string, string> = {
id,
type: wsType,
room_id: roomId,
content: filePathHint,
};
if (reply) payload.reply = reply;
if (displayName) payload.file_name = displayName;
const existingIdx = messages.value.findIndex((m) => m.id === id);
if (existingIdx >= 0) {
const next = [...messages.value];
next[existingIdx] = {
...next[existingIdx],
content: filePathHint,
type,
mediaUrl,
fileName: displayName,
uploadProgress: 100,
optimistic: false,
delivery: 'sent',
};
messages.value = next;
} else {
const optimistic: ChatMessage = {
id,
roomId,
senderId: session.chatUserId,
content: filePathHint,
type,
createdAt: new Date().toISOString(),
reply,
optimistic: true,
delivery: 'sending',
mediaUrl,
fileName: displayName,
localPreviewUrl: opts?.localPreviewUrl,
};
messages.value = mergeMessages(optimistic, messages.value);
}
emitRoomLive({
roomId,
preview: previewLabelForMessage({
type,
content: filePathHint,
fileName: displayName,
}),
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);
let fileForUpload = file;
let outboundFileName = file.name;
if (type === 'video') {
// Camouflage as image path so external chat WS delivers the message.
// Keep original name (with video ext) for UI inference via file_name.
const original =
looksLikeVideo(file.name) ? file.name : `${file.name.replace(/\.[^.]+$/, '') || 'video'}.mp4`;
outboundFileName = original;
fileForUpload = new File([file], `vid-${Date.now()}.jpg`, {
type: 'image/jpeg',
});
} else {
const prepared = await prepareFileForUploadService(file, type);
fileForUpload = prepared.uploadFile;
outboundFileName = prepared.displayName;
if (type === 'image') {
fileForUpload = await compressImageIfNeeded(fileForUpload);
// اگر فشرده‌سازی نام را عوض کرد (مثلاً webp)، همان را برای نمایش نگه دار
if (fileForUpload.name && fileForUpload.name !== prepared.uploadFile.name) {
outboundFileName = fileForUpload.name;
}
}
}
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: outboundFileName,
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;
}
}
async function editMessage(roomId: string, messageId: string, text: string) {
const trimmed = text.trim();
if (!trimmed || !messageId) return;
await connectSocket();
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open');
messages.value = messages.value.map((m) =>
m.id === messageId
? {
...m,
content: trimmed,
type: 'text',
edited: true,
deleted: false,
mediaUrl: undefined,
fileName: undefined,
}
: m,
);
socket.send(
JSON.stringify({
type: 'edit',
id: messageId,
room_id: roomId,
content: trimmed,
}),
);
emitRoomLive({
roomId,
preview: trimmed,
at: new Date().toISOString(),
fromSelf: true,
});
}
async function deleteMessage(
roomId: string,
messageId: string,
opts?: { forEveryone?: boolean },
) {
if (!messageId) return;
const forEveryone = opts?.forEveryone !== false;
// 1) Persist hide in CRM
await persistHiddenOnServer(roomId, [messageId], forEveryone);
// 2) Realtime sync only when deleting for everyone
if (forEveryone) {
await connectSocket();
if (socket && socket.readyState === WebSocket.OPEN) {
const controlId = crypto.randomUUID();
rememberHidden(roomId, [controlId]);
socket.send(
JSON.stringify({
id: controlId,
type: 'text',
room_id: roomId,
content: `${HIDE_CMD_PREFIX}${messageId}`,
}),
);
void persistHiddenOnServer(roomId, [controlId], true);
}
}
const last = [...messages.value].reverse().find((m) => m.roomId === roomId);
emitRoomLive({
roomId,
preview: last ? previewLabelForMessage(last) : ' ',
at: new Date().toISOString(),
fromSelf: true,
});
}
async function deleteMessages(
roomId: string,
items: Array<{ id: string; forEveryone?: boolean }>,
) {
const seen = new Set<string>();
for (const item of items) {
const id = String(item.id || '').trim();
if (!id || seen.has(id)) continue;
seen.add(id);
await deleteMessage(roomId, id, { forEveryone: item.forEveryone !== false });
}
}
async function forwardMessages(targetRoomId: string, sourceMessages: ChatMessage[]) {
const roomId = targetRoomId.trim();
if (!roomId) return;
await connectSocket();
for (const msg of sourceMessages) {
if (!msg || msg.deleted || msg.optimistic) continue;
if (msg.type === 'text') {
const text = msg.content.trim();
if (text && !isDeletedContent(text)) await sendText(roomId, text);
continue;
}
const path = String(msg.content || '').trim();
if (!path) continue;
await sendMediaPath(roomId, path, msg.type, {
fileName: msg.fileName,
});
}
}
async function pinChatMessage(roomId: string, msg: ChatMessage) {
const messageId = String(msg.id || '').trim();
if (!roomId || !messageId || msg.optimistic) return;
const preview = previewLabelForMessage(msg).slice(0, 240);
await api.post('/conversations/messages/pin', {
externalRoomId: roomId,
messageId,
preview,
});
emitMessagePin({ roomId, messageId, preview, fromSelf: true });
await connectSocket();
if (socket && socket.readyState === WebSocket.OPEN) {
const controlId = crypto.randomUUID();
rememberHidden(roomId, [controlId]);
socket.send(
JSON.stringify({
id: controlId,
type: 'text',
room_id: roomId,
content: `${PIN_CMD_PREFIX}${messageId}`,
}),
);
void persistHiddenOnServer(roomId, [controlId], true);
}
}
async function unpinChatMessage(roomId: string) {
if (!roomId) return;
await api.post('/conversations/messages/unpin', { externalRoomId: roomId });
emitMessagePin({ roomId, messageId: null, preview: '', fromSelf: true });
await connectSocket();
if (socket && socket.readyState === WebSocket.OPEN) {
const controlId = crypto.randomUUID();
rememberHidden(roomId, [controlId]);
socket.send(
JSON.stringify({
id: controlId,
type: 'text',
room_id: roomId,
content: UNPIN_CMD,
}),
);
void persistHiddenOnServer(roomId, [controlId], true);
}
}
async function toggleMessageReaction(roomId: string, messageId: string, emoji: string) {
const rid = String(roomId || '').trim();
const mid = String(messageId || '').trim();
const em = String(emoji || '').trim().slice(0, 16);
if (!rid || !mid || !em) return null;
const { data } = await api.post<{
added: boolean;
reactions: MessageReactionAgg[];
}>('/conversations/messages/reactions', {
externalRoomId: rid,
messageId: mid,
emoji: em,
});
emitMessageReaction({
roomId: rid,
messageId: mid,
emoji: em,
added: Boolean(data?.added),
fromSelf: true,
});
try {
await connectSocket();
if (socket && socket.readyState === WebSocket.OPEN) {
const controlId = crypto.randomUUID();
rememberHidden(rid, [controlId]);
socket.send(
JSON.stringify({
id: controlId,
type: 'text',
room_id: rid,
content: `${REACT_CMD_PREFIX}${mid}:${em}:${data?.added ? '1' : '0'}`,
}),
);
void persistHiddenOnServer(rid, [controlId], true);
}
} catch {
// ignore
}
return Array.isArray(data?.reactions) ? data.reactions : null;
}
function clearActiveRoom() {
stopHiddenPoll();
activeRoomId.value = '';
messages.value = [];
}
function resetSession() {
wantSocket = false;
sessionCache = null;
stopHiddenPoll();
stopTypingPoll();
disconnectSocket();
messages.value = [];
activeRoomId.value = '';
}
return {
status: computed(() => status.value),
messages: computed(() => messages.value),
unreadTotal: computed(() => unreadTotal.value),
loadingMessages,
hasMoreMessages: computed(() => hasMoreMessages.value),
loadingOlderMessages: computed(() => loadingOlderMessages.value),
typingMap,
loadMessages,
loadOlderMessages,
sendTyping,
typingUserIdsForRoom,
sendText,
sendFile,
editMessage,
deleteMessage,
deleteMessages,
forwardMessages,
pinChatMessage,
unpinChatMessage,
toggleMessageReaction,
sendRead,
ensureConnected,
setConversationsPageOpen,
clearActiveRoom,
resetSession,
ensureSession,
refreshUnreadTotal,
};
}