2026-09-29 16:22:37 +03:30
|
|
|
import { computed, ref, shallowRef } from 'vue';
|
|
|
|
|
import type { ConversationRoomDto, ConversationSessionDto } from '@mento/shared';
|
2026-09-29 10:34:01 +03:30
|
|
|
import { api } from '@/lib/api';
|
|
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
export type ChatDelivery = 'sending' | 'sent' | 'read';
|
|
|
|
|
|
2026-09-29 17:22:34 +03:30
|
|
|
export type ChatMediaType = 'text' | 'image' | 'voice' | 'video' | 'file';
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
export type ChatMessage = {
|
|
|
|
|
id: string;
|
|
|
|
|
roomId: string;
|
|
|
|
|
senderId: string;
|
|
|
|
|
content: string;
|
2026-09-29 17:22:34 +03:30
|
|
|
type: ChatMediaType;
|
2026-09-29 10:34:01 +03:30
|
|
|
createdAt: string;
|
|
|
|
|
reply: string | null;
|
|
|
|
|
optimistic?: boolean;
|
2026-09-29 16:22:37 +03:30
|
|
|
delivery?: ChatDelivery;
|
|
|
|
|
readAt?: string | null;
|
2026-09-29 17:22:34 +03:30
|
|
|
/** Absolute URL for media display/download */
|
|
|
|
|
mediaUrl?: string;
|
|
|
|
|
fileName?: string;
|
|
|
|
|
uploadProgress?: number;
|
|
|
|
|
localPreviewUrl?: string;
|
2026-09-29 21:06:04 +03:30
|
|
|
edited?: boolean;
|
|
|
|
|
deleted?: boolean;
|
2026-09-29 16:22:37 +03:30
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type RoomLiveEvent = {
|
|
|
|
|
roomId: string;
|
|
|
|
|
preview: string;
|
|
|
|
|
at: string;
|
|
|
|
|
fromSelf: boolean;
|
2026-09-29 10:34:01 +03:30
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const PAGE_SIZE = 50;
|
2026-09-29 17:22:34 +03:30
|
|
|
const IMAGE_MAX_EDGE = 1600;
|
|
|
|
|
const IMAGE_WEBP_QUALITY = 0.82;
|
|
|
|
|
const MAX_UPLOAD_BYTES = 40 * 1024 * 1024;
|
2026-09-29 21:46:41 +03:30
|
|
|
/** Synced hard-delete marker (legacy edit attempts). */
|
2026-09-29 21:36:31 +03:30
|
|
|
const HARD_DELETE_MARKER = '__MENTO_DEL__';
|
2026-09-29 21:46:41 +03:30
|
|
|
/** Realtime hide command delivered as a normal text message (external WS delivers text reliably). */
|
|
|
|
|
const HIDE_CMD_PREFIX = '__MENTO_HIDE__:';
|
2026-09-29 21:26:07 +03:30
|
|
|
|
|
|
|
|
function isDeletedContent(content: string): boolean {
|
2026-09-29 21:36:31 +03:30
|
|
|
const t = content.trim();
|
|
|
|
|
return (
|
|
|
|
|
/^(?:\[?\s*deleted\s*\]?|این پیام حذف شد|پیام حذف شد)$/i.test(t) ||
|
|
|
|
|
t.includes('MENTO_DEL') ||
|
2026-09-29 21:46:41 +03:30
|
|
|
t === HARD_DELETE_MARKER ||
|
|
|
|
|
t.startsWith(HIDE_CMD_PREFIX)
|
2026-09-29 21:36:31 +03:30
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 21:46:41 +03:30
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 21:36:31 +03:30
|
|
|
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;
|
2026-09-29 21:26:07 +03:30
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
2026-09-29 21:46:41 +03:30
|
|
|
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[]) {
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
} 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
let socket: WebSocket | null = null;
|
|
|
|
|
let sessionCache: ConversationSessionDto | null = null;
|
2026-09-29 16:22:37 +03:30
|
|
|
let connectPromise: Promise<void> | null = null;
|
|
|
|
|
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
let wantSocket = false;
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
const status = ref<'idle' | 'connecting' | 'open' | 'closed'>('idle');
|
|
|
|
|
const messages = shallowRef<ChatMessage[]>([]);
|
|
|
|
|
const activeRoomId = ref('');
|
2026-09-29 16:22:37 +03:30
|
|
|
const unreadTotal = ref(0);
|
|
|
|
|
const pageOpen = ref(false);
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
2026-09-29 11:56:32 +03:30
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 17:22:34 +03:30
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 20:36:15 +03:30
|
|
|
const VIDEO_EXT_RE = /\.(mp4|mov|m4v|mkv|avi|3gp|ogv)(?:\?|$)/i;
|
2026-09-29 20:18:17 +03:30
|
|
|
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;
|
2026-09-29 20:36:15 +03:30
|
|
|
const VOICE_NAME_RE = /(?:^|[\\/._-])voice(?:[\\/._-]|$)/i;
|
2026-09-29 20:48:14 +03:30
|
|
|
/** Uploaded chat videos are camouflaged as .jpg so the external WS accepts them as image. */
|
|
|
|
|
const VIDEO_CAMO_RE = /(?:^|[\\/._-])vid-\d+/i;
|
2026-09-29 20:18:17 +03:30
|
|
|
|
|
|
|
|
function looksLikeVideo(pathOrName: string): boolean {
|
|
|
|
|
return VIDEO_EXT_RE.test(pathOrName);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 20:36:15 +03:30
|
|
|
function looksLikeVoiceAsset(pathOrName: string): boolean {
|
|
|
|
|
return VOICE_NAME_RE.test(pathOrName) || AUDIO_EXT_RE.test(pathOrName);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 20:48:14 +03:30
|
|
|
function looksLikeCamouflagedVideo(pathOrName: string): boolean {
|
|
|
|
|
return VIDEO_CAMO_RE.test(pathOrName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function looksLikeVideoAsset(pathOrName: string): boolean {
|
|
|
|
|
return looksLikeVideo(pathOrName) || looksLikeCamouflagedVideo(pathOrName);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 20:18:17 +03:30
|
|
|
function inferMediaType(
|
|
|
|
|
declared: ChatMediaType,
|
|
|
|
|
content: string,
|
|
|
|
|
fileName?: string,
|
|
|
|
|
): ChatMediaType {
|
|
|
|
|
const probe = `${fileName || ''} ${content}`;
|
2026-09-29 20:48:14 +03:30
|
|
|
// 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';
|
2026-09-29 20:36:15 +03:30
|
|
|
if (declared === 'video') return 'video';
|
|
|
|
|
if (declared === 'image') return 'image';
|
2026-09-29 20:18:17 +03:30
|
|
|
if (declared === 'file' || declared === 'text') {
|
|
|
|
|
if (IMAGE_EXT_RE.test(probe)) return 'image';
|
|
|
|
|
if (declared === 'file') return 'file';
|
|
|
|
|
}
|
|
|
|
|
return declared;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 17:22:34 +03:30
|
|
|
export function previewLabelForMessage(msg: Pick<ChatMessage, 'type' | 'content' | 'fileName'>) {
|
|
|
|
|
if (msg.type === 'image') return 'تصویر';
|
2026-09-29 20:18:17 +03:30
|
|
|
if (msg.type === 'voice') return 'پیام صوتی';
|
2026-09-29 17:22:34 +03:30
|
|
|
if (msg.type === 'video') return 'ویدیو';
|
2026-09-29 20:18:17 +03:30
|
|
|
if (msg.type === 'file') {
|
2026-09-29 20:48:14 +03:30
|
|
|
if (looksLikeVideoAsset(`${msg.fileName || ''} ${msg.content}`)) return 'ویدیو';
|
2026-09-29 20:36:15 +03:30
|
|
|
if (looksLikeVoiceAsset(`${msg.fileName || ''} ${msg.content}`)) return 'پیام صوتی';
|
2026-09-29 20:18:17 +03:30
|
|
|
return msg.fileName?.trim() || 'فایل';
|
|
|
|
|
}
|
2026-09-29 17:22:34 +03:30
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 20:36:15 +03:30
|
|
|
/** Classify upload/attach files for chat UI + WS. Exported for composer pending previews. */
|
|
|
|
|
export function classifyChatFile(file: File): Exclude<ChatMediaType, 'text'> {
|
2026-09-29 17:22:34 +03:30
|
|
|
const mime = (file.type || '').toLowerCase();
|
2026-09-29 20:36:15 +03:30
|
|
|
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';
|
2026-09-29 17:22:34 +03:30
|
|
|
return 'file';
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 20:36:15 +03:30
|
|
|
function classifyFile(file: File): Exclude<ChatMediaType, 'text'> {
|
|
|
|
|
return classifyChatFile(file);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-29 20:48:14 +03:30
|
|
|
* 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.
|
2026-09-29 20:36:15 +03:30
|
|
|
*/
|
2026-09-29 20:18:17 +03:30
|
|
|
function wsMediaType(type: ChatMediaType): 'image' | 'voice' | 'file' | 'text' {
|
2026-09-29 20:48:14 +03:30
|
|
|
if (type === 'image' || type === 'video') return 'image';
|
2026-09-29 17:22:34 +03:30
|
|
|
if (type === 'voice') return 'voice';
|
2026-09-29 20:36:15 +03:30
|
|
|
if (type === 'file') return 'file';
|
2026-09-29 20:18:17 +03:30
|
|
|
return 'text';
|
2026-09-29 17:22:34 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function compressImageIfNeeded(file: File): Promise<File> {
|
|
|
|
|
if (!file.type.startsWith('image/')) return file;
|
|
|
|
|
if (file.type === 'image/gif' || file.type === 'image/svg+xml') return file;
|
|
|
|
|
if (file.size < 350_000) return file;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const bitmap = await createImageBitmap(file);
|
|
|
|
|
const ratio = Math.min(
|
|
|
|
|
IMAGE_MAX_EDGE / bitmap.width,
|
|
|
|
|
IMAGE_MAX_EDGE / bitmap.height,
|
|
|
|
|
1,
|
|
|
|
|
);
|
|
|
|
|
const width = Math.round(bitmap.width * ratio);
|
|
|
|
|
const height = Math.round(bitmap.height * ratio);
|
|
|
|
|
const canvas = document.createElement('canvas');
|
|
|
|
|
canvas.width = width;
|
|
|
|
|
canvas.height = height;
|
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
|
if (!ctx) {
|
|
|
|
|
bitmap.close();
|
|
|
|
|
return file;
|
|
|
|
|
}
|
|
|
|
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
|
|
|
bitmap.close();
|
|
|
|
|
|
|
|
|
|
const blob = await new Promise<Blob | null>((resolve) => {
|
|
|
|
|
canvas.toBlob((b) => resolve(b), 'image/webp', IMAGE_WEBP_QUALITY);
|
|
|
|
|
});
|
|
|
|
|
if (!blob || blob.size >= file.size) return file;
|
|
|
|
|
|
|
|
|
|
const base = file.name.replace(/\.[^/.]+$/, '') || 'image';
|
|
|
|
|
return new File([blob], `${base}.webp`, {
|
|
|
|
|
type: 'image/webp',
|
|
|
|
|
lastModified: Date.now(),
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
return file;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 11:56:32 +03:30
|
|
|
function normalizeIncoming(
|
|
|
|
|
raw: Record<string, unknown>,
|
|
|
|
|
roomFallback: string,
|
|
|
|
|
): ChatMessage | null {
|
2026-09-29 10:34:01 +03:30
|
|
|
const base =
|
|
|
|
|
raw.message && typeof raw.message === 'object'
|
|
|
|
|
? (raw.message as Record<string, unknown>)
|
|
|
|
|
: raw;
|
2026-09-29 20:18:17 +03:30
|
|
|
const declared = normalizeMediaType(String(base.type_content ?? base.type ?? 'text'));
|
2026-09-29 20:48:14 +03:30
|
|
|
const content = String(
|
|
|
|
|
base.content ??
|
|
|
|
|
base.text ??
|
|
|
|
|
base.body ??
|
|
|
|
|
base.file_url ??
|
|
|
|
|
base.fileUrl ??
|
|
|
|
|
base.url ??
|
|
|
|
|
base.path ??
|
|
|
|
|
'',
|
|
|
|
|
).trim();
|
2026-09-29 10:34:01 +03:30
|
|
|
if (!content && !base.attachments) return null;
|
2026-09-29 17:22:34 +03:30
|
|
|
|
2026-09-29 11:56:32 +03:30
|
|
|
const id = String(base.id ?? base.message_id ?? '').trim();
|
2026-09-29 16:22:37 +03:30
|
|
|
const readAt = base.read_at ?? base.readAt ?? null;
|
2026-09-29 17:22:34 +03:30
|
|
|
const downloadUrl = sessionCache?.downloadUrl ?? '';
|
2026-09-29 20:18:17 +03:30
|
|
|
const fileName =
|
|
|
|
|
String(base.file_name ?? base.fileName ?? base.name ?? '').trim() ||
|
|
|
|
|
content.split('/').pop() ||
|
|
|
|
|
undefined;
|
2026-09-29 21:46:41 +03:30
|
|
|
const roomId = String(base.room_id ?? raw.room_id ?? roomFallback);
|
2026-09-29 20:18:17 +03:30
|
|
|
const type = inferMediaType(declared, content, fileName);
|
2026-09-29 17:22:34 +03:30
|
|
|
const isMedia = type !== 'text';
|
|
|
|
|
const mediaUrl = isMedia ? buildChatFileUrl(content, downloadUrl) : undefined;
|
2026-09-29 21:36:31 +03:30
|
|
|
const deletedFlag = Boolean(base.deleted || base.is_deleted || base.isDeleted);
|
2026-09-29 21:46:41 +03:30
|
|
|
const hideTarget = parseHideCommand(content);
|
|
|
|
|
if (
|
|
|
|
|
deletedFlag ||
|
|
|
|
|
isDeletedContent(content) ||
|
|
|
|
|
hideTarget ||
|
|
|
|
|
(id && isRememberedHidden(roomId, id))
|
|
|
|
|
) {
|
2026-09-29 21:36:31 +03:30
|
|
|
return null;
|
|
|
|
|
}
|
2026-09-29 21:06:04 +03:30
|
|
|
const edited = Boolean(base.edited || base.is_edited || base.isEdited || base.edited_at);
|
2026-09-29 17:22:34 +03:30
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
return {
|
2026-09-29 11:56:32 +03:30
|
|
|
id: id || `tmp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
2026-09-29 21:46:41 +03:30
|
|
|
roomId,
|
2026-09-29 10:34:01 +03:30
|
|
|
senderId: String(
|
|
|
|
|
base.sender_id ?? base.user_id ?? base.from_user_id ?? raw.sender_id ?? '',
|
|
|
|
|
),
|
2026-09-29 21:36:31 +03:30
|
|
|
content,
|
|
|
|
|
type,
|
2026-09-29 10:34:01 +03:30
|
|
|
createdAt: String(
|
|
|
|
|
base.created_at ?? base.createdAt ?? raw.created_at ?? new Date().toISOString(),
|
|
|
|
|
),
|
|
|
|
|
reply: base.reply ? String(base.reply) : null,
|
2026-09-29 16:22:37 +03:30
|
|
|
readAt: readAt ? String(readAt) : null,
|
|
|
|
|
delivery: readAt ? 'read' : 'sent',
|
2026-09-29 21:36:31 +03:30
|
|
|
mediaUrl,
|
|
|
|
|
fileName,
|
|
|
|
|
edited,
|
|
|
|
|
deleted: false,
|
2026-09-29 10:34:01 +03:30
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 11:56:32 +03:30
|
|
|
function mergeMessages(incoming: ChatMessage, list: ChatMessage[]): ChatMessage[] {
|
2026-09-29 21:36:31 +03:30
|
|
|
if (incoming.deleted || isDeletedContent(incoming.content)) {
|
|
|
|
|
return list.filter((m) => m.id !== incoming.id);
|
|
|
|
|
}
|
2026-09-29 11:56:32 +03:30
|
|
|
const byId = list.findIndex((m) => m.id === incoming.id);
|
|
|
|
|
if (byId >= 0) {
|
2026-09-29 16:22:37 +03:30
|
|
|
const prev = list[byId];
|
2026-09-29 21:36:31 +03:30
|
|
|
if (prev.deleted) {
|
|
|
|
|
return list.filter((m) => m.id !== prev.id);
|
|
|
|
|
}
|
2026-09-29 11:56:32 +03:30
|
|
|
const next = [...list];
|
2026-09-29 16:22:37 +03:30
|
|
|
next[byId] = {
|
|
|
|
|
...prev,
|
|
|
|
|
...incoming,
|
|
|
|
|
optimistic: false,
|
2026-09-29 21:36:31 +03:30
|
|
|
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,
|
|
|
|
|
),
|
2026-09-29 17:22:34 +03:30
|
|
|
localPreviewUrl: incoming.localPreviewUrl ?? prev.localPreviewUrl,
|
2026-09-29 21:36:31 +03:30
|
|
|
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
|
|
|
|
fileName: incoming.fileName || prev.fileName,
|
2026-09-29 21:06:04 +03:30
|
|
|
edited: incoming.edited || prev.edited,
|
2026-09-29 21:36:31 +03:30
|
|
|
deleted: false,
|
2026-09-29 16:22:37 +03:30
|
|
|
delivery:
|
|
|
|
|
incoming.delivery === 'read' || prev.delivery === 'read'
|
|
|
|
|
? 'read'
|
|
|
|
|
: incoming.delivery || prev.delivery || 'sent',
|
|
|
|
|
readAt: incoming.readAt ?? prev.readAt,
|
|
|
|
|
};
|
2026-09-29 11:56:32 +03:30
|
|
|
return sortAsc(next);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const optimisticIdx = list.findIndex(
|
|
|
|
|
(m) =>
|
|
|
|
|
m.optimistic &&
|
|
|
|
|
m.roomId === incoming.roomId &&
|
|
|
|
|
m.senderId === incoming.senderId &&
|
2026-09-29 17:22:34 +03:30
|
|
|
(m.content === incoming.content ||
|
|
|
|
|
(m.type === incoming.type &&
|
|
|
|
|
m.type !== 'text' &&
|
|
|
|
|
Boolean(m.localPreviewUrl))),
|
2026-09-29 11:56:32 +03:30
|
|
|
);
|
|
|
|
|
if (optimisticIdx >= 0) {
|
2026-09-29 16:22:37 +03:30
|
|
|
const prev = list[optimisticIdx];
|
2026-09-29 11:56:32 +03:30
|
|
|
const next = [...list];
|
|
|
|
|
next[optimisticIdx] = {
|
2026-09-29 16:22:37 +03:30
|
|
|
...prev,
|
2026-09-29 11:56:32 +03:30
|
|
|
...incoming,
|
|
|
|
|
optimistic: false,
|
2026-09-29 20:36:15 +03:30
|
|
|
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,
|
|
|
|
|
),
|
2026-09-29 20:18:17 +03:30
|
|
|
localPreviewUrl: prev.localPreviewUrl,
|
2026-09-29 17:22:34 +03:30
|
|
|
uploadProgress: undefined,
|
|
|
|
|
mediaUrl: incoming.mediaUrl || prev.mediaUrl,
|
2026-09-29 20:18:17 +03:30
|
|
|
fileName: incoming.fileName || prev.fileName,
|
2026-09-29 21:06:04 +03:30
|
|
|
edited: incoming.edited || prev.edited,
|
|
|
|
|
deleted: incoming.deleted || prev.deleted,
|
2026-09-29 16:22:37 +03:30
|
|
|
delivery: incoming.readAt ? 'read' : 'sent',
|
2026-09-29 11:56:32 +03:30
|
|
|
};
|
2026-09-29 17:22:34 +03:30
|
|
|
if (prev.localPreviewUrl?.startsWith('blob:')) {
|
|
|
|
|
try {
|
|
|
|
|
URL.revokeObjectURL(prev.localPreviewUrl);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-29 11:56:32 +03:30
|
|
|
return sortAsc(next);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sortAsc([...list, incoming]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
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 };
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
function clearReconnect() {
|
|
|
|
|
if (reconnectTimer) {
|
|
|
|
|
clearTimeout(reconnectTimer);
|
|
|
|
|
reconnectTimer = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function scheduleReconnect() {
|
|
|
|
|
if (!wantSocket || reconnectTimer) return;
|
|
|
|
|
reconnectTimer = setTimeout(() => {
|
|
|
|
|
reconnectTimer = null;
|
|
|
|
|
void connectSocket().catch(() => {
|
|
|
|
|
scheduleReconnect();
|
|
|
|
|
});
|
|
|
|
|
}, 2500);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
function disconnectSocket() {
|
|
|
|
|
clearPing();
|
2026-09-29 16:22:37 +03:30
|
|
|
clearReconnect();
|
2026-09-29 10:34:01 +03:30
|
|
|
if (socket) {
|
2026-09-29 16:22:37 +03:30
|
|
|
socket.onmessage = null;
|
|
|
|
|
socket.onclose = null;
|
|
|
|
|
socket.onerror = null;
|
2026-09-29 10:34:01 +03:30
|
|
|
socket.close();
|
|
|
|
|
socket = null;
|
|
|
|
|
}
|
|
|
|
|
status.value = 'closed';
|
2026-09-29 16:22:37 +03:30
|
|
|
connectPromise = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleIncomingMessage(msg: ChatMessage) {
|
|
|
|
|
const myId = sessionCache?.chatUserId ?? '';
|
|
|
|
|
const fromSelf = Boolean(myId && msg.senderId === myId);
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
unreadTotal.value += 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
emitRoomLive({
|
|
|
|
|
roomId: msg.roomId,
|
2026-09-29 17:22:34 +03:30
|
|
|
preview: previewLabelForMessage(msg),
|
2026-09-29 16:22:37 +03:30
|
|
|
at: msg.createdAt,
|
|
|
|
|
fromSelf,
|
|
|
|
|
});
|
2026-09-29 10:34:01 +03:30
|
|
|
}
|
|
|
|
|
|
2026-09-29 21:06:04 +03:30
|
|
|
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;
|
2026-09-29 21:36:31 +03:30
|
|
|
// Hard-delete sync uses edit channel with purge marker
|
2026-09-29 21:26:07 +03:30
|
|
|
if (isDeletedContent(content)) {
|
2026-09-29 21:36:31 +03:30
|
|
|
purgeMessageById(id);
|
2026-09-29 21:26:07 +03:30
|
|
|
return;
|
|
|
|
|
}
|
2026-09-29 21:06:04 +03:30
|
|
|
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();
|
2026-09-29 21:36:31 +03:30
|
|
|
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();
|
2026-09-29 21:06:04 +03:30
|
|
|
}
|
|
|
|
|
|
2026-09-29 11:56:32 +03:30
|
|
|
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>;
|
2026-09-29 16:22:37 +03:30
|
|
|
const type = String(parsed.type ?? parsed.event ?? '').toLowerCase();
|
|
|
|
|
if (type === 'pong' || type === 'ping' || type === 'typing' || type === 'read') return;
|
|
|
|
|
if (type === 'seen') {
|
|
|
|
|
applySeenPayload(parsed);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-29 21:06:04 +03:30
|
|
|
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;
|
|
|
|
|
}
|
2026-09-29 21:36:31 +03:30
|
|
|
const purgeId = extractPayloadId(parsed);
|
|
|
|
|
const purgeContent = extractPayloadContent(parsed);
|
2026-09-29 21:46:41 +03:30
|
|
|
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);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-29 21:36:31 +03:30
|
|
|
if (purgeId && isDeletedContent(purgeContent)) {
|
|
|
|
|
purgeMessageById(purgeId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-29 11:56:32 +03:30
|
|
|
const msg = normalizeIncoming(parsed, activeRoomId.value);
|
2026-09-29 16:22:37 +03:30
|
|
|
if (!msg) return;
|
2026-09-29 21:46:41 +03:30
|
|
|
if (isRememberedHidden(msg.roomId, msg.id)) return;
|
2026-09-29 16:22:37 +03:30
|
|
|
handleIncomingMessage(msg);
|
2026-09-29 11:56:32 +03:30
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
async function connectSocket() {
|
2026-09-29 16:22:37 +03:30
|
|
|
wantSocket = true;
|
2026-09-29 10:34:01 +03:30
|
|
|
if (socket?.readyState === WebSocket.OPEN) return;
|
2026-09-29 16:22:37 +03:30
|
|
|
if (connectPromise) return connectPromise;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
connectPromise = (async () => {
|
|
|
|
|
const session = await ensureSession();
|
|
|
|
|
if (socket?.readyState === WebSocket.OPEN) return;
|
2026-09-29 10:34:01 +03:30
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
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;
|
2026-09-29 10:34:01 +03:30
|
|
|
});
|
|
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
return connectPromise;
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
function sendRead(roomId: string) {
|
|
|
|
|
if (!socket || socket.readyState !== WebSocket.OPEN || !roomId) return;
|
|
|
|
|
socket.send(JSON.stringify({ type: 'read', room_id: roomId }));
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
|
2026-09-29 20:18:17 +03:30
|
|
|
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 (err && details) return `آپلود ناموفق بود: ${err} (${details})`;
|
|
|
|
|
if (err) return `آپلود ناموفق بود: ${err}`;
|
|
|
|
|
} catch {
|
|
|
|
|
const snippet = raw.slice(0, 160);
|
|
|
|
|
if (snippet) return `آپلود ناموفق بود (${status}): ${snippet}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (status === 500) {
|
|
|
|
|
return 'آپلود ناموفق بود (۵۰۰). سرویس ذخیرهسازی فایل (S3) روی آدرس آپلود در دسترس نیست — تنظیمات upload/download در سوپرادمین را بررسی کنید.';
|
|
|
|
|
}
|
|
|
|
|
return `آپلود ناموفق بود (${status})`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 17:22:34 +03:30
|
|
|
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) {
|
2026-09-29 20:18:17 +03:30
|
|
|
reject(new Error(formatUploadFailure(xhr.status, xhr.responseText || '')));
|
2026-09-29 17:22:34 +03:30
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
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;
|
|
|
|
|
}
|
2026-09-29 10:34:01 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function useOrgChat() {
|
|
|
|
|
const loadingMessages = ref(false);
|
|
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
async function ensureConnected() {
|
|
|
|
|
await connectSocket();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setConversationsPageOpen(open: boolean) {
|
|
|
|
|
pageOpen.value = open;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
async function loadMessages(roomId: string) {
|
|
|
|
|
activeRoomId.value = roomId;
|
|
|
|
|
loadingMessages.value = true;
|
2026-09-29 21:46:41 +03:30
|
|
|
startHiddenPoll(roomId);
|
2026-09-29 10:34:01 +03:30
|
|
|
try {
|
|
|
|
|
const session = await ensureSession();
|
|
|
|
|
await connectSocket();
|
2026-09-29 21:46:41 +03:30
|
|
|
const hiddenIds = await fetchHiddenFromServer(roomId);
|
|
|
|
|
const hiddenSet = new Set(hiddenIds);
|
|
|
|
|
|
2026-09-29 10:34:01 +03:30
|
|
|
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 ?? [];
|
2026-09-29 21:46:41 +03:30
|
|
|
|
|
|
|
|
// Collect hide-command targets from history so we filter even without CRM row yet
|
|
|
|
|
for (const item of list as Record<string, unknown>[]) {
|
|
|
|
|
const rawContent = String(
|
|
|
|
|
item.content ?? item.text ?? item.body ?? '',
|
|
|
|
|
).trim();
|
|
|
|
|
const target = parseHideCommand(rawContent);
|
|
|
|
|
if (target) hiddenSet.add(target);
|
|
|
|
|
}
|
|
|
|
|
rememberHidden(roomId, [...hiddenSet]);
|
|
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
const myId = session.chatUserId;
|
2026-09-29 21:54:54 +03:30
|
|
|
const normalized: ChatMessage[] = [];
|
|
|
|
|
for (const item of list as Record<string, unknown>[]) {
|
|
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-09-29 11:56:32 +03:30
|
|
|
messages.value = sortAsc(normalized);
|
2026-09-29 16:22:37 +03:30
|
|
|
sendRead(roomId);
|
2026-09-29 10:34:01 +03:30
|
|
|
} finally {
|
|
|
|
|
loadingMessages.value = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
2026-09-29 16:22:37 +03:30
|
|
|
delivery: 'sending',
|
2026-09-29 10:34:01 +03:30
|
|
|
};
|
2026-09-29 11:56:32 +03:30
|
|
|
messages.value = mergeMessages(optimistic, messages.value);
|
2026-09-29 16:22:37 +03:30
|
|
|
emitRoomLive({
|
|
|
|
|
roomId,
|
|
|
|
|
preview: trimmed,
|
|
|
|
|
at: optimistic.createdAt,
|
|
|
|
|
fromSelf: true,
|
|
|
|
|
});
|
2026-09-29 10:34:01 +03:30
|
|
|
socket.send(JSON.stringify(payload));
|
2026-09-29 16:22:37 +03:30
|
|
|
messages.value = messages.value.map((m) =>
|
|
|
|
|
m.id === id ? { ...m, optimistic: false, delivery: 'sent' as const } : m,
|
|
|
|
|
);
|
2026-09-29 10:34:01 +03:30
|
|
|
}
|
|
|
|
|
|
2026-09-29 17:22:34 +03:30
|
|
|
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);
|
2026-09-29 20:48:14 +03:30
|
|
|
// Never put real video extensions in WS content — external chat drops those messages.
|
2026-09-29 20:36:15 +03:30
|
|
|
let filePathHint = filePath;
|
2026-09-29 20:48:14 +03:30
|
|
|
if (type === 'voice' && !/\.[a-z0-9]{2,5}(?:\?|$)/i.test(filePathHint)) {
|
2026-09-29 20:36:15 +03:30
|
|
|
const ext = opts?.fileName?.match(/(\.[a-z0-9]{2,5})$/i)?.[1];
|
|
|
|
|
if (ext) filePathHint = `${filePathHint}${ext}`;
|
|
|
|
|
}
|
2026-09-29 20:48:14 +03:30
|
|
|
const displayName =
|
|
|
|
|
type === 'video'
|
|
|
|
|
? opts?.fileName && looksLikeVideo(opts.fileName)
|
|
|
|
|
? opts.fileName
|
|
|
|
|
: opts?.fileName
|
|
|
|
|
? opts.fileName.replace(/\.[^.]+$/, '') + '.mp4'
|
|
|
|
|
: 'video.mp4'
|
|
|
|
|
: opts?.fileName;
|
2026-09-29 20:36:15 +03:30
|
|
|
const mediaUrl = buildChatFileUrl(filePathHint, session.downloadUrl);
|
2026-09-29 17:22:34 +03:30
|
|
|
|
|
|
|
|
const payload: Record<string, string> = {
|
|
|
|
|
id,
|
|
|
|
|
type: wsType,
|
|
|
|
|
room_id: roomId,
|
2026-09-29 20:36:15 +03:30
|
|
|
content: filePathHint,
|
2026-09-29 17:22:34 +03:30
|
|
|
};
|
|
|
|
|
if (reply) payload.reply = reply;
|
2026-09-29 20:48:14 +03:30
|
|
|
if (displayName) payload.file_name = displayName;
|
2026-09-29 17:22:34 +03:30
|
|
|
|
|
|
|
|
const existingIdx = messages.value.findIndex((m) => m.id === id);
|
|
|
|
|
if (existingIdx >= 0) {
|
|
|
|
|
const next = [...messages.value];
|
|
|
|
|
next[existingIdx] = {
|
|
|
|
|
...next[existingIdx],
|
2026-09-29 20:36:15 +03:30
|
|
|
content: filePathHint,
|
2026-09-29 17:22:34 +03:30
|
|
|
type,
|
|
|
|
|
mediaUrl,
|
2026-09-29 20:48:14 +03:30
|
|
|
fileName: displayName,
|
2026-09-29 17:22:34 +03:30
|
|
|
uploadProgress: 100,
|
|
|
|
|
optimistic: false,
|
|
|
|
|
delivery: 'sent',
|
|
|
|
|
};
|
|
|
|
|
messages.value = next;
|
|
|
|
|
} else {
|
|
|
|
|
const optimistic: ChatMessage = {
|
|
|
|
|
id,
|
|
|
|
|
roomId,
|
|
|
|
|
senderId: session.chatUserId,
|
2026-09-29 20:36:15 +03:30
|
|
|
content: filePathHint,
|
2026-09-29 17:22:34 +03:30
|
|
|
type,
|
|
|
|
|
createdAt: new Date().toISOString(),
|
|
|
|
|
reply,
|
|
|
|
|
optimistic: true,
|
|
|
|
|
delivery: 'sending',
|
|
|
|
|
mediaUrl,
|
2026-09-29 20:48:14 +03:30
|
|
|
fileName: displayName,
|
2026-09-29 17:22:34 +03:30
|
|
|
localPreviewUrl: opts?.localPreviewUrl,
|
|
|
|
|
};
|
|
|
|
|
messages.value = mergeMessages(optimistic, messages.value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
emitRoomLive({
|
|
|
|
|
roomId,
|
2026-09-29 20:36:15 +03:30
|
|
|
preview: previewLabelForMessage({
|
|
|
|
|
type,
|
|
|
|
|
content: filePathHint,
|
2026-09-29 20:48:14 +03:30
|
|
|
fileName: displayName,
|
2026-09-29 20:36:15 +03:30
|
|
|
}),
|
2026-09-29 17:22:34 +03:30
|
|
|
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);
|
2026-09-29 20:36:15 +03:30
|
|
|
let fileForUpload = type === 'image' ? await compressImageIfNeeded(file) : file;
|
2026-09-29 20:48:14 +03:30
|
|
|
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: file.type || 'video/mp4',
|
2026-09-29 20:36:15 +03:30
|
|
|
});
|
|
|
|
|
}
|
2026-09-29 17:22:34 +03:30
|
|
|
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,
|
2026-09-29 20:48:14 +03:30
|
|
|
fileName: outboundFileName,
|
2026-09-29 17:22:34 +03:30
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 21:06:04 +03:30
|
|
|
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) {
|
|
|
|
|
if (!messageId) return;
|
|
|
|
|
await connectSocket();
|
|
|
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error('ws not open');
|
|
|
|
|
|
2026-09-29 21:46:41 +03:30
|
|
|
// 1) Persist hide in CRM (survives refresh for everyone)
|
|
|
|
|
await persistHiddenOnServer(roomId, [messageId]);
|
2026-09-29 21:06:04 +03:30
|
|
|
|
2026-09-29 21:46:41 +03:30
|
|
|
// 2) Realtime: send a normal text command (text delivery is reliable on chat WS)
|
|
|
|
|
const controlId = crypto.randomUUID();
|
|
|
|
|
rememberHidden(roomId, [controlId]);
|
2026-09-29 21:26:07 +03:30
|
|
|
socket.send(
|
|
|
|
|
JSON.stringify({
|
2026-09-29 21:46:41 +03:30
|
|
|
id: controlId,
|
|
|
|
|
type: 'text',
|
2026-09-29 21:26:07 +03:30
|
|
|
room_id: roomId,
|
2026-09-29 21:46:41 +03:30
|
|
|
content: `${HIDE_CMD_PREFIX}${messageId}`,
|
2026-09-29 21:26:07 +03:30
|
|
|
}),
|
|
|
|
|
);
|
2026-09-29 21:46:41 +03:30
|
|
|
// Hide the control message itself so it never appears in history UI
|
|
|
|
|
void persistHiddenOnServer(roomId, [controlId]);
|
2026-09-29 21:36:31 +03:30
|
|
|
|
|
|
|
|
const last = [...messages.value].reverse().find((m) => m.roomId === roomId);
|
2026-09-29 21:06:04 +03:30
|
|
|
emitRoomLive({
|
|
|
|
|
roomId,
|
2026-09-29 21:36:31 +03:30
|
|
|
preview: last ? previewLabelForMessage(last) : ' ',
|
2026-09-29 21:06:04 +03:30
|
|
|
at: new Date().toISOString(),
|
|
|
|
|
fromSelf: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 21:26:07 +03:30
|
|
|
async function deleteMessages(roomId: string, messageIds: string[]) {
|
|
|
|
|
const ids = [...new Set(messageIds.filter(Boolean))];
|
|
|
|
|
for (const id of ids) {
|
|
|
|
|
await deleteMessage(roomId, id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-29 16:22:37 +03:30
|
|
|
function clearActiveRoom() {
|
2026-09-29 21:46:41 +03:30
|
|
|
stopHiddenPoll();
|
2026-09-29 16:22:37 +03:30
|
|
|
activeRoomId.value = '';
|
|
|
|
|
messages.value = [];
|
2026-09-29 10:34:01 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetSession() {
|
2026-09-29 16:22:37 +03:30
|
|
|
wantSocket = false;
|
2026-09-29 10:34:01 +03:30
|
|
|
sessionCache = null;
|
2026-09-29 21:46:41 +03:30
|
|
|
stopHiddenPoll();
|
2026-09-29 10:34:01 +03:30
|
|
|
disconnectSocket();
|
|
|
|
|
messages.value = [];
|
|
|
|
|
activeRoomId.value = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
status: computed(() => status.value),
|
|
|
|
|
messages: computed(() => messages.value),
|
2026-09-29 16:22:37 +03:30
|
|
|
unreadTotal: computed(() => unreadTotal.value),
|
2026-09-29 10:34:01 +03:30
|
|
|
loadingMessages,
|
|
|
|
|
loadMessages,
|
|
|
|
|
sendText,
|
2026-09-29 17:22:34 +03:30
|
|
|
sendFile,
|
2026-09-29 21:06:04 +03:30
|
|
|
editMessage,
|
|
|
|
|
deleteMessage,
|
2026-09-29 21:26:07 +03:30
|
|
|
deleteMessages,
|
|
|
|
|
forwardMessages,
|
2026-09-29 10:34:01 +03:30
|
|
|
sendRead,
|
2026-09-29 16:22:37 +03:30
|
|
|
ensureConnected,
|
|
|
|
|
setConversationsPageOpen,
|
|
|
|
|
clearActiveRoom,
|
2026-09-29 10:34:01 +03:30
|
|
|
resetSession,
|
|
|
|
|
ensureSession,
|
2026-09-29 16:22:37 +03:30
|
|
|
refreshUnreadTotal,
|
2026-09-29 10:34:01 +03:30
|
|
|
};
|
|
|
|
|
}
|