import { renderHeader } from '../components/header.js'; import { directMessages } from '../mock-data.js'; import { addAppLogEntry, addChatMessage, addSystemChatMessage, addOutgoingPendingMessage, getChatMessages, markChatRead, markOutgoingSent, markReadReceiptSentByBaseKey, authService, setContacts, state, } from '../state.js'; import { startOutgoingCall } from '../services/call-service.js'; import { openSpeechInputModal } from '../components/speech-input-modal.js'; import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js'; import { showToast } from '../services/channels-ux.js'; export const pageMeta = { id: 'chat-view', title: 'Чат' }; function openMessageActionsModal({ messageText = '', onReadAloud }) { const root = document.getElementById('modal-root'); if (!root) return; root.innerHTML = ` `; const close = () => { root.innerHTML = ''; }; root.querySelector('#chat-message-actions-modal-overlay')?.addEventListener('click', (event) => { if (event.target?.id === 'chat-message-actions-modal-overlay') close(); }); root.querySelector('#msg-action-copy')?.addEventListener('click', async () => { try { if (navigator?.clipboard?.writeText) { await navigator.clipboard.writeText(String(messageText || '')); } showToast('Сообщение скопированно', { timeoutMs: 1000 }); } catch { showToast('Не удалось скопировать сообщение', { kind: 'error', timeoutMs: 1200 }); } finally { close(); } }); root.querySelector('#msg-action-read')?.addEventListener('click', async () => { close(); if (typeof onReadAloud === 'function') await onReadAloud(); }); } function showTtsMissingConfigDialog(navigate) { const root = document.getElementById('modal-root'); if (!root) return; root.innerHTML = ` `; const close = () => { root.innerHTML = ''; }; root.querySelector('#chat-tts-no')?.addEventListener('click', close); root.querySelector('#chat-tts-yes')?.addEventListener('click', () => { close(); navigate('tools-settings-view'); }); } function autoResizeComposer(textarea) { if (!textarea) return; textarea.style.height = 'auto'; textarea.style.height = `${Math.min(180, Math.max(42, textarea.scrollHeight))}px`; } function openConfirmContactModal(targetLogin = '') { const root = document.getElementById('modal-root'); if (!root) return Promise.resolve(false); return new Promise((resolve) => { root.innerHTML = ` `; const close = (answer) => { root.innerHTML = ''; resolve(!!answer); }; root.querySelector('#contact-confirm-no')?.addEventListener('click', () => close(false)); root.querySelector('#contact-confirm-yes')?.addEventListener('click', () => close(true)); }); } function parseBaseKey(baseKey) { const raw = String(baseKey || '').trim(); const parts = raw.split('|'); if (parts.length < 4) return null; const fromLogin = parts[0] || ''; const toLogin = parts[1] || ''; const timeMs = Number(parts[2] || 0); const nonce = Number(parts[3] || 0); if (!fromLogin || !toLogin || !Number.isFinite(timeMs) || !Number.isFinite(nonce)) return null; return { fromLogin, toLogin, timeMs, nonce }; } function resolveMessageTimeMs(msg) { const base = parseBaseKey(msg?.baseKey); if (base?.timeMs && Number.isFinite(base.timeMs) && base.timeMs > 0) return base.timeMs; const messageKey = String(msg?.messageKey || '').trim(); if (messageKey) { const parts = messageKey.split('|'); const timeMs = Number(parts[2] || 0); if (parts.length >= 4 && Number.isFinite(timeMs) && timeMs > 0) return timeMs; } const tempId = String(msg?.tempId || '').trim(); if (tempId.startsWith('tmp-')) { const ts = Number(tempId.split('-')[1] || 0); if (Number.isFinite(ts) && ts > 0) return ts; } const fallback = Number(msg?.createdAtMs || 0); if (Number.isFinite(fallback) && fallback > 0) return fallback; return 0; } function formatMessageTime(valueMs) { const timeMs = Number(valueMs || 0); if (!Number.isFinite(timeMs) || timeMs <= 0) return ''; return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit', }).format(new Date(timeMs)); } function resolveDeliveryStatus(msg) { if (msg?.from !== 'out') return ''; if (msg?.secondTick) return '✓✓'; if (msg?.firstTick) return '✓'; return '…'; } function scrollToLatestMessage(list) { if (!list) return; const apply = () => { list.scrollTop = list.scrollHeight; }; apply(); window.requestAnimationFrame(apply); window.requestAnimationFrame(() => window.requestAnimationFrame(apply)); window.setTimeout(apply, 0); window.setTimeout(apply, 60); window.setTimeout(apply, 120); window.setTimeout(apply, 260); } function renderLog(list, chatId, { onOpenActions } = {}) { list.innerHTML = ''; const messages = getChatMessages(chatId); let unreadSeparatorInserted = false; messages.forEach((msg) => { if (!unreadSeparatorInserted && msg?.from === 'in' && msg?.unread) { const sep = document.createElement('div'); sep.className = 'chat-unread-separator'; const label = document.createElement('span'); label.textContent = 'Новые сообщения'; sep.append(label); list.append(sep); unreadSeparatorInserted = true; } const bubble = document.createElement('div'); const bubbleKind = String(msg?.kind || '').trim(); bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`; const textNode = document.createElement('div'); textNode.className = 'bubble-text'; textNode.textContent = msg.text || ''; const metaNode = document.createElement('div'); metaNode.className = 'bubble-meta'; const timeNode = document.createElement('span'); timeNode.className = 'bubble-time'; timeNode.textContent = formatMessageTime(resolveMessageTimeMs(msg)); metaNode.append(timeNode); const status = resolveDeliveryStatus(msg); if (status) { const statusNode = document.createElement('span'); statusNode.className = 'bubble-status'; statusNode.textContent = status; metaNode.append(statusNode); } bubble.append(textNode, metaNode); bubble.addEventListener('click', () => { if (typeof onOpenActions === 'function') onOpenActions(msg); }); list.append(bubble); }); scrollToLatestMessage(list); markChatRead(chatId); } export function render({ navigate, route }) { const chatId = route.params.chatId || 'u1'; const contact = directMessages.find((d) => d.id === chatId) || { id: chatId, name: chatId, initials: (chatId[0] || '?').toUpperCase(), }; const screen = document.createElement('section'); screen.className = 'stack dm-screen dm-chat-screen'; const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase()); screen.append( renderHeader({ title: `Чат с ${contact.name}`, leftAction: { label: '←', onClick: () => navigate('messages-list') }, rightActions: [{ label: 'Позвонить', onClick: async () => { try { await startOutgoingCall(chatId); renderLog(log, chatId, { onOpenActions: (msg) => openMessageActionsModal({ messageText: msg?.text || '', onReadAloud: async () => { if (!isTextToSpeechConfigured(state.entrySettings)) { showTtsMissingConfigDialog(navigate); return; } await speakTextBySettings(String(msg?.text || ''), state.entrySettings); }, }), }); } catch (e) { addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, { from: 'out', kind: 'call-tech', }); renderLog(log, chatId, { onOpenActions: (msg) => openMessageActionsModal({ messageText: msg?.text || '', onReadAloud: async () => { if (!isTextToSpeechConfigured(state.entrySettings)) { showTtsMissingConfigDialog(navigate); return; } await speakTextBySettings(String(msg?.text || ''), state.entrySettings); }, }), }); } }, }], }) ); if (!isKnownContact) { const card = document.createElement('div'); card.className = 'card'; const btn = document.createElement('button'); btn.className = 'secondary-btn'; btn.type = 'button'; btn.textContent = 'Добавить собеседника в контакты'; btn.addEventListener('click', async () => { try { const approved = await openConfirmContactModal(chatId); if (!approved) return; await authService.setUserRelation({ login: state.session.login, toLogin: chatId, kind: 'contact', enabled: true, storagePwd: state.session.storagePwdInMemory, }); const contactsPayload = await authService.listContacts(); setContacts(contactsPayload?.contacts || []); addAppLogEntry({ level: 'info', source: 'contacts', message: `Пользователь ${chatId} добавлен в контакты`, }); card.remove(); } catch (e) { addAppLogEntry({ level: 'warn', source: 'contacts', message: 'Не удалось добавить пользователя в контакты', details: { login: chatId, error: e?.message || 'unknown' }, }); } }); card.append(btn); screen.append(card); } const wrap = document.createElement('div'); wrap.className = 'chat-wrap dm-chat-wrap'; const log = document.createElement('div'); log.className = 'messages-log dm-messages-log'; const form = document.createElement('form'); form.className = 'chat-input dm-chat-input'; form.innerHTML = `
`; const sendTextMessage = async (rawText) => { const text = String(rawText || '').trim(); if (!text) return; const tempId = addOutgoingPendingMessage(chatId, text); renderLog(log, chatId, { onOpenActions: (msg) => openMessageActionsModal({ messageText: msg?.text || '', onReadAloud: async () => { if (!isTextToSpeechConfigured(state.entrySettings)) { showTtsMissingConfigDialog(navigate); return; } await speakTextBySettings(String(msg?.text || ''), state.entrySettings); }, }), }); try { const result = await authService.sendDirectMessage({ login: state.session.login, toLogin: chatId, text, storagePwd: state.session.storagePwdInMemory, }); markOutgoingSent(tempId, { messageKey: result?.outgoingKey || '', baseKey: result?.baseKey || result?.localBaseKey || '', }); renderLog(log, chatId, { onOpenActions: (msg) => openMessageActionsModal({ messageText: msg?.text || '', onReadAloud: async () => { if (!isTextToSpeechConfigured(state.entrySettings)) { showTtsMissingConfigDialog(navigate); return; } await speakTextBySettings(String(msg?.text || ''), state.entrySettings); }, }), }); addAppLogEntry({ level: 'info', source: 'outgoing-dm', message: `Сообщение отправлено для ${chatId}`, details: { toLogin: chatId, messageId: result?.outgoingKey || '', deliveredWsSessions: Number(result?.deliveredWsSessions || 0), deliveredWebPushSessions: Number(result?.deliveredWebPushSessions || 0), }, }); } catch (e) { addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`); addAppLogEntry({ level: 'warn', source: 'outgoing-dm', message: 'Ошибка отправки личного сообщения', details: { toLogin: chatId, error: e?.message || 'unknown', }, }); renderLog(log, chatId, { onOpenActions: (msg) => openMessageActionsModal({ messageText: msg?.text || '', onReadAloud: async () => { if (!isTextToSpeechConfigured(state.entrySettings)) { showTtsMissingConfigDialog(navigate); return; } await speakTextBySettings(String(msg?.text || ''), state.entrySettings); }, }), }); } }; const input = form.elements.message; autoResizeComposer(input); input?.addEventListener('input', () => autoResizeComposer(input)); input?.addEventListener('focus', () => { scrollToLatestMessage(log); }); input?.addEventListener('keydown', async (event) => { if (event.key !== 'Enter') return; if (event.ctrlKey) { event.preventDefault(); const start = Number(input.selectionStart ?? input.value.length); const end = Number(input.selectionEnd ?? input.value.length); const value = String(input.value || ''); input.value = `${value.slice(0, start)}\n${value.slice(end)}`; const nextPos = start + 1; try { input.setSelectionRange(nextPos, nextPos); } catch { // ignore } autoResizeComposer(input); return; } event.preventDefault(); const text = String(input.value || '').trim(); if (!text) return; input.value = ''; autoResizeComposer(input); await sendTextMessage(text); }); form.querySelector('#chat-voice-input')?.addEventListener('click', async () => { await openSpeechInputModal({ navigate, onTextReady: (text) => { const prev = String(input.value || '').trim(); input.value = prev ? `${prev} ${text}` : text; autoResizeComposer(input); }, onSendText: async (text) => sendTextMessage(text), onSendQueued: () => { showToast('Сообщение будет отправлено автоматически после распознавания', { timeoutMs: 1000 }); }, }); }); form.addEventListener('submit', async (event) => { event.preventDefault(); const text = input.value.trim(); if (!text) return; input.value = ''; autoResizeComposer(input); await sendTextMessage(text); }); wrap.append(log, form); screen.append(wrap); renderLog(log, chatId, { onOpenActions: (msg) => openMessageActionsModal({ messageText: msg?.text || '', onReadAloud: async () => { if (!isTextToSpeechConfigured(state.entrySettings)) { showTtsMissingConfigDialog(navigate); return; } await speakTextBySettings(String(msg?.text || ''), state.entrySettings); }, }), }); window.requestAnimationFrame(() => scrollToLatestMessage(log)); window.setTimeout(() => scrollToLatestMessage(log), 180); void sendReadReceiptsForVisible(chatId); return screen; } async function sendReadReceiptsForVisible(chatId) { const pending = getChatMessages(chatId) .filter((row) => row?.from === 'in' && Number(row?.messageType) === 1 && !row?.readReceiptSent) .slice(0, 50); for (const row of pending) { const ref = parseBaseKey(row.baseKey); if (!ref) continue; try { await authService.sendReadReceipt({ login: state.session.login, toLogin: ref.fromLogin, storagePwd: state.session.storagePwdInMemory, refToLogin: ref.toLogin, refFromLogin: ref.fromLogin, refTimeMs: ref.timeMs, refNonce: ref.nonce, refType: 1, }); if (row.baseKey) { markReadReceiptSentByBaseKey(row.baseKey); } else { row.readReceiptSent = true; } } catch (e) { addAppLogEntry({ level: 'warn', source: 'read-receipt', message: 'Не удалось отправить подтверждение прочтения', details: { chatId, messageKey: row.messageKey || '', error: e?.message || 'unknown' }, }); } } }