import { renderHeader } from '../components/header.js'; import { directMessages } from '../mock-data.js'; import { addAppLogEntry, addChatMessage, addSignedMessageToChat, 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 truncatePreviewText(value, maxLen = 72) { const normalized = String(value || '').replace(/\s+/g, ' ').trim(); if (!normalized) return ''; if (normalized.length <= maxLen) return normalized; return `${normalized.slice(0, Math.max(0, maxLen - 1)).trimEnd()}…`; } function openDeleteMessageConfirmModal({ onConfirm }) { const root = document.getElementById('modal-root'); if (!root) return; root.innerHTML = ` `; const close = () => { root.innerHTML = ''; }; root.querySelector('#chat-delete-message-no')?.addEventListener('click', close); root.querySelector('#chat-delete-message-yes')?.addEventListener('click', async () => { close(); if (typeof onConfirm === 'function') await onConfirm(); }); } function openMessageActionsMenu({ anchorX = 0, anchorY = 0, messageText = '', canEdit = false, canDelete = false, onReadAloud, onEdit, onDelete, }) { const root = document.getElementById('modal-root'); if (!root) return; const menuId = `chat-message-actions-menu-${Date.now()}`; root.innerHTML = `
`; const menu = root.querySelector(`#${menuId}`); if (!menu) return; const close = () => { document.removeEventListener('pointerdown', onDocumentPointerDown, true); window.removeEventListener('resize', close); window.removeEventListener('scroll', close, true); root.innerHTML = ''; }; const onDocumentPointerDown = (event) => { if (menu.contains(event.target)) return; close(); }; document.addEventListener('pointerdown', onDocumentPointerDown, true); window.addEventListener('resize', close); window.addEventListener('scroll', close, true); window.requestAnimationFrame(() => { const menuRect = menu.getBoundingClientRect(); const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0); const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0); const left = Math.min( Math.max(12, Number(anchorX || 0) - (menuRect.width / 2)), Math.max(12, viewportWidth - menuRect.width - 12) ); const top = Math.min( Math.max(12, Number(anchorY || 0) + 10), Math.max(12, viewportHeight - menuRect.height - 12) ); menu.style.left = `${left}px`; menu.style.top = `${top}px`; menu.style.transformOrigin = `${Math.round(Number(anchorX || left) - left)}px top`; menu.classList.add('is-visible'); }); 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(); }); root.querySelector('#msg-action-edit')?.addEventListener('click', async () => { close(); if (typeof onEdit === 'function') await onEdit(); }); root.querySelector('#msg-action-delete')?.addEventListener('click', async () => { close(); if (typeof onDelete === 'function') await onDelete(); }); } 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 resolveMessageEditedTimeMs(msg) { const revisionTimeMs = Number(msg?.revisionTimeMs || 0); if (!Number.isFinite(revisionTimeMs) || revisionTimeMs <= 0) return 0; return revisionTimeMs; } 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 || ''; bubble.append(textNode); 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(metaNode); const editedAtMs = resolveMessageEditedTimeMs(msg); if (editedAtMs > 0) { const editedNode = document.createElement('div'); editedNode.className = 'bubble-meta bubble-meta-edited'; editedNode.textContent = `изменено: ${formatMessageTime(editedAtMs)}`; bubble.append(editedNode); } bubble.addEventListener('click', (event) => { if (typeof onOpenActions === 'function') onOpenActions(msg, event); }); 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()); const handleReadAloud = async (msg) => { if (!isTextToSpeechConfigured(state.entrySettings)) { showTtsMissingConfigDialog(navigate); return; } await speakTextBySettings(String(msg?.text || ''), state.entrySettings); }; const wrap = document.createElement('div'); wrap.className = 'chat-wrap dm-chat-wrap'; const log = document.createElement('div'); log.className = 'messages-log dm-messages-log'; screen.append( renderHeader({ title: `Чат с ${contact.name}`, leftAction: { label: '←', onClick: () => navigate('messages-list') }, rightActions: [{ label: 'Позвонить', onClick: async () => { try { await startOutgoingCall(chatId); renderLog(log, chatId, { onOpenActions: handleOpenActions }); } catch (e) { addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, { from: 'out', kind: 'call-tech', }); renderLog(log, chatId, { onOpenActions: handleOpenActions }); } }, }], }) ); 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 form = document.createElement('form'); form.className = 'chat-input dm-chat-input'; form.innerHTML = `
`; const input = form.elements.message; const editBanner = form.querySelector('#chat-edit-banner'); const editBannerText = form.querySelector('#chat-edit-banner-text'); const editCancelBtn = form.querySelector('#chat-edit-cancel'); let activeEdit = null; const syncEditBanner = () => { if (!editBanner || !editBannerText) return; if (!activeEdit) { editBanner.hidden = true; editBannerText.textContent = ''; return; } editBanner.hidden = false; editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`; }; const focusInputToEnd = () => { if (!input) return; input.focus(); try { const nextPos = String(input.value || '').length; input.setSelectionRange(nextPos, nextPos); } catch { // ignore } }; const cancelEditMode = ({ restoreDraft = true } = {}) => { if (!activeEdit) return; const draftToRestore = activeEdit.draftBeforeEdit; activeEdit = null; syncEditBanner(); if (restoreDraft && input) { input.value = draftToRestore || ''; autoResizeComposer(input); focusInputToEnd(); } }; const startEditMode = (msg) => { const base = parseBaseKey(msg?.baseKey); if (!base || msg?.from !== 'out') return; const currentDraft = String(input?.value || ''); activeEdit = { messageKey: String(msg?.messageKey || ''), originalText: String(msg?.text || ''), draftBeforeEdit: activeEdit ? String(activeEdit.draftBeforeEdit || '') : currentDraft, timeMs: base.timeMs, nonce: base.nonce, }; syncEditBanner(); if (input) { input.value = String(msg?.text || ''); autoResizeComposer(input); focusInputToEnd(); } }; const applyLocalRevision = ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => { if (!localOutgoingBlobB64) return; try { const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64); addSignedMessageToChat({ chatId, messageKey: fallbackMessageKey || parsed?.messageKey || '', baseKey: fallbackBaseKey || parsed?.baseKey || '', from: 'out', text: parsed?.text || '', messageType: Number(parsed?.messageType || 2), unread: false, rawBlobB64: localOutgoingBlobB64, revisionTimeMs: Number(parsed?.revisionTimeMs || 0), deleted: Boolean(parsed?.deleted), }); } catch { // ignore local parse failure; server backlog/realtime will reconcile later } }; const sendDeleteRevision = async (msg) => { const base = parseBaseKey(msg?.baseKey); if (!base) return; const result = await authService.deleteDirectMessage({ login: state.session.login, toLogin: chatId, storagePwd: state.session.storagePwdInMemory, timeMs: base.timeMs, nonce: base.nonce, revisionTimeMs: Date.now(), }); applyLocalRevision({ localOutgoingBlobB64: result?.localOutgoingBlobB64 || '', fallbackMessageKey: result?.outgoingKey || '', fallbackBaseKey: result?.baseKey || result?.localBaseKey || '', }); if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) { cancelEditMode({ restoreDraft: true }); } renderLog(log, chatId, { onOpenActions: handleOpenActions }); }; const sendTextMessage = async (rawText) => { const text = String(rawText || '').trim(); if (!text) return; const editing = activeEdit; const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text); renderLog(log, chatId, { onOpenActions: handleOpenActions }); try { let result; if (editing) { result = await authService.sendDirectMessageRevision({ login: state.session.login, toLogin: chatId, text, storagePwd: state.session.storagePwdInMemory, timeMs: editing.timeMs, nonce: editing.nonce, revisionTimeMs: Date.now(), }); } else { 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 || '', }); } applyLocalRevision({ localOutgoingBlobB64: result?.localOutgoingBlobB64 || '', fallbackMessageKey: result?.outgoingKey || '', fallbackBaseKey: result?.baseKey || result?.localBaseKey || '', }); if (editing) { cancelEditMode({ restoreDraft: true }); } renderLog(log, chatId, { onOpenActions: handleOpenActions }); addAppLogEntry({ level: 'info', source: 'outgoing-dm', message: editing ? `Сообщение изменено для ${chatId}` : `Сообщение отправлено для ${chatId}`, details: { toLogin: chatId, messageId: result?.outgoingKey || '', deliveredWsSessions: Number(result?.deliveredWsSessions || 0), deliveredWebPushSessions: Number(result?.deliveredWebPushSessions || 0), }, }); } catch (e) { if (input) { input.value = text; autoResizeComposer(input); focusInputToEnd(); } addChatMessage(chatId, `${activeEdit ? 'Ошибка изменения' : 'Ошибка отправки'}: ${e.message || 'unknown'}`); addAppLogEntry({ level: 'warn', source: 'outgoing-dm', message: activeEdit ? 'Ошибка редактирования личного сообщения' : 'Ошибка отправки личного сообщения', details: { toLogin: chatId, error: e?.message || 'unknown', }, }); renderLog(log, chatId, { onOpenActions: handleOpenActions }); } }; const handleOpenActions = (msg, event) => { openMessageActionsMenu({ anchorX: Number(event?.clientX || 0), anchorY: Number(event?.clientY || 0), messageText: msg?.text || '', canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2, canDelete: msg?.from === 'out' && Number(msg?.messageType || 0) === 2, onReadAloud: async () => handleReadAloud(msg), onEdit: async () => { startEditMode(msg); }, onDelete: async () => { openDeleteMessageConfirmModal({ onConfirm: async () => { try { await sendDeleteRevision(msg); } catch (error) { showToast(`Не удалось удалить сообщение: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1500 }); } }, }); }, }); }; editCancelBtn?.addEventListener('click', () => { cancelEditMode({ restoreDraft: true }); }); 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 = String(input.value || '').trim(); if (!text) return; input.value = ''; autoResizeComposer(input); await sendTextMessage(text); }); wrap.append(log, form); screen.append(wrap); renderLog(log, chatId, { onOpenActions: handleOpenActions }); 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' }, }); } } }