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(); 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(); 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(); 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>({}); const mentionSelfName = ref(''); export function setChatMutePrefs( prefs: Record, 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) { 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>(); let hiddenPollTimer: ReturnType | 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 { 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 | null = null; let pingTimer: ReturnType | null = null; let reconnectTimer: ReturnType | null = null; let wantSocket = false; const status = ref<'idle' | 'connecting' | 'open' | 'closed'>('idle'); const messages = shallowRef([]); 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> >({}); let typingSweepTimer: ReturnType | null = null; let typingPollTimer: ReturnType | null = null; function startTypingSweep() { if (typingSweepTimer) return; typingSweepTimer = setInterval(() => { const now = Date.now(); let changed = false; const next: Record> = {}; 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> = {}; 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) { 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 { const nested = (result.data && typeof result.data === 'object' ? (result.data as Record) : null); const file = (result.file && typeof result.file === 'object' ? (result.file as Record) : 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 { 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 { 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 { 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((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, ): 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 { 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((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, roomFallback: string, ): ChatMessage | null { const base = raw.message && typeof raw.message === 'object' ? (raw.message as Record) : 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) { 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 { if (sessionCache) return sessionCache; const { data } = await api.get('/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) { const base = payload.message && typeof payload.message === 'object' ? (payload.message as Record) : 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) { const base = payload.message && typeof payload.message === 'object' ? (payload.message as Record) : payload; const id = String(base.id ?? base.message_id ?? payload.message_id ?? '').trim(); purgeMessageById(id); } function extractPayloadContent(parsed: Record): string { const base = parsed.message && typeof parsed.message === 'object' ? (parsed.message as Record) : parsed; return String( base.content ?? base.text ?? base.body ?? parsed.content ?? '', ).trim(); } function extractPayloadId(parsed: Record): string { const base = parsed.message && typeof parsed.message === 'object' ? (parsed.message as Record) : 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; 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 | 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 | 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 | 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) : 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 | 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 | 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((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; 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 { 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; 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('/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[], roomId: string, hiddenSet: Set, 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[]; 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 { 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[]; 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 = { 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, 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 = { 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(); 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, }; }