import { renderHeader } from '../components/header.js'; import { directMessages } from '../mock-data.js'; import { addAppLogEntry, addChatMessage, addSignedMessageToChat, addSystemChatMessage, addOutgoingPendingMessage, deleteConversationMessagesBefore, getChatMessages, markChatRead, markOutgoingSent, markReadReceiptSentByBaseKey, normalizeDmChatId, authService, setContacts, state, } from '../state.js'; import { startOutgoingCall } from '../services/call-service.js'; import { createEmojiPicker, createTelegramAnimatedEmoji, appendTelegramAnimatedText, isTelegramAnimatedEmoji, stopAllTelegramEmojiAnimations, } from '../components/emoji-picker.js?v=202607152130'; import { openSpeechInputModal } from '../components/speech-input-modal.js'; import { isSpeechToTextConfigured, isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js'; import { showToast } from '../services/channels-ux.js'; import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; export const pageMeta = { id: 'chat-view', title: 'Чат' }; const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места'; 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 findMessageByBaseKey(messages, baseKey) { const normalized = String(baseKey || '').trim(); if (!normalized) return null; return messages.find((row) => String(row?.baseKey || '').trim() === normalized) || null; } 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 openChatConfirmModal({ title = 'Подтвердить действие', text = '', confirmLabel = 'Да', confirmKind = 'primary', onConfirm, }) { const root = document.getElementById('modal-root'); if (!root) return; const confirmClass = confirmKind === 'danger' ? 'destructive-btn' : 'primary-btn'; root.innerHTML = ` `; const close = () => { root.innerHTML = ''; }; root.querySelector('#chat-confirm-no')?.addEventListener('click', close); root.querySelector('#chat-confirm-yes')?.addEventListener('click', async () => { close(); if (typeof onConfirm === 'function') await onConfirm(); }); } function openDeleteChatConfirmModal({ contactName = '', onConfirm }) { const root = document.getElementById('modal-root'); if (!root) return; root.innerHTML = ` `; const close = () => { root.innerHTML = ''; }; root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close); root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => { const deleteHistory = Boolean(root.querySelector('#chat-delete-chat-history')?.checked); close(); if (typeof onConfirm === 'function') { await onConfirm({ deleteHistory }); } }); } function openMessageActionsMenu({ anchorX = 0, anchorY = 0, messageText = '', canReply = false, showReadAloud = true, canEdit = false, canDelete = false, onReply, 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-reply')?.addEventListener('click', async () => { close(); if (typeof onReply === 'function') await onReply(); }); 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 openChatActionsMenu({ anchorX = 0, anchorY = 0, onCall, onClearHistory, onDeleteChat, }) { const root = document.getElementById('modal-root'); if (!root) return; const menuId = `chat-header-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 + 8), 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('#chat-menu-call')?.addEventListener('click', async () => { close(); if (typeof onCall === 'function') await onCall(); }); root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => { close(); if (typeof onClearHistory === 'function') await onClearHistory(); }); root.querySelector('#chat-menu-delete-chat')?.addEventListener('click', async () => { close(); if (typeof onDeleteChat === 'function') await onDeleteChat(); }); } 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 formatCallDurationCompact(totalSeconds) { const total = Math.max(0, Math.floor(Number(totalSeconds || 0))); const hours = Math.floor(total / 3600); const minutes = Math.floor((total % 3600) / 60); const seconds = total % 60; if (hours > 0) { return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; } return `${minutes}:${String(seconds).padStart(2, '0')}`; } function buildCallStatusText(callSummary) { if (!callSummary) return ''; if (callSummary.status === 'completed') { return `Длительность: ${formatCallDurationCompact(callSummary.durationSec)}`; } const reason = String(callSummary.reason || '').trim().toLowerCase(); if (reason === 'offline') return 'Не дозвонился: абонент не в сети'; if (reason === 'no_answer') return 'Не дозвонился: нет ответа'; if (reason === 'connect_failed') return 'Не дозвонился: не удалось установить соединение'; if (reason === 'busy') return 'Не дозвонился: абонент занят'; if (reason === 'declined') return 'Не дозвонился: звонок отклонён'; return 'Не дозвонился'; } 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 scrollContainer = list.closest('.screen-content') || list.parentElement || list; const lastBubble = list.lastElementChild; const apply = () => { if (lastBubble?.scrollIntoView) { lastBubble.scrollIntoView({ block: 'end', inline: 'nearest' }); } scrollContainer.scrollTop = scrollContainer.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 renderMessageText(textNode, messageText) { const text = String(messageText || ''); const emoji = text.trim(); if (text === emoji && isTelegramAnimatedEmoji(emoji)) { textNode.classList.add('bubble-text--animated-emoji'); textNode.append(createTelegramAnimatedEmoji(emoji, { className: 'chat-telegram-emoji' })); return; } if (appendTelegramAnimatedText(textNode, text, { className: 'chat-telegram-emoji chat-telegram-emoji--inline' })) { textNode.classList.add('bubble-text--telegram-emoji-inline'); } } function scrollToLatestMessageSmart(list, { smoothIfNearBottom = false } = {}) { if (!list) return; const scrollContainer = list.closest('.screen-content') || list.parentElement || list; const lastBubble = list.lastElementChild; const maxScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight); const distanceToBottom = Math.max(0, maxScrollTop - scrollContainer.scrollTop); const useSmooth = smoothIfNearBottom && distanceToBottom <= 180; const apply = (behavior = useSmooth ? 'smooth' : 'auto') => { if (lastBubble?.scrollIntoView) { lastBubble.scrollIntoView({ block: 'end', inline: 'nearest', behavior }); } if (behavior === 'smooth' && typeof scrollContainer.scrollTo === 'function') { scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' }); } else { scrollContainer.scrollTop = scrollContainer.scrollHeight; } }; apply(useSmooth ? 'smooth' : 'auto'); window.requestAnimationFrame(() => apply('auto')); window.setTimeout(() => apply('auto'), useSmooth ? 220 : 90); } function scrollToUnreadSeparator(list) { if (!list) return false; const separator = list.querySelector('.chat-unread-separator'); if (!separator) return false; const scrollContainer = list.closest('.screen-content') || list.parentElement || list; const apply = () => { if (separator?.scrollIntoView) { separator.scrollIntoView({ block: 'start', inline: 'nearest' }); } const bottomSlack = 72; scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - bottomSlack); }; apply(); window.requestAnimationFrame(apply); window.requestAnimationFrame(() => window.requestAnimationFrame(apply)); window.setTimeout(apply, 60); window.setTimeout(apply, 160); return true; } function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode = 'latest' } = {}) { 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 parsedText = parseDmTechBlocks(String(msg?.text || '')); const autoKind = parsedText.callSummary ? 'call-tech' : ''; const bubbleKind = String(msg?.kind || autoKind || '').trim(); bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`; if (parsedText.callSummary) { const callCard = document.createElement('div'); callCard.className = 'bubble-call-card'; const callHead = document.createElement('div'); callHead.className = 'bubble-call-head'; const callIcon = document.createElement('span'); callIcon.className = 'bubble-call-icon'; callIcon.textContent = '☎'; const callTitle = document.createElement('span'); callTitle.className = 'bubble-call-title'; callTitle.textContent = msg?.from === 'out' ? 'Исходящий звонок' : 'Входящий звонок'; callHead.append(callIcon, callTitle); const callMetaLine = document.createElement('div'); callMetaLine.className = 'bubble-call-line bubble-call-line--muted'; const callStatus = buildCallStatusText(parsedText.callSummary); callMetaLine.textContent = callStatus; callCard.append(callHead, callMetaLine); bubble.append(callCard); } const textNode = document.createElement('div'); textNode.className = 'bubble-text'; renderMessageText(textNode, parsedText.displayText || ''); if (parsedText.replyRef) { const replyTarget = findMessageByBaseKey(messages, parsedText.replyRef.baseKey); if (replyTarget) { const replyParsed = parseDmTechBlocks(String(replyTarget?.text || '')); const replyBox = document.createElement('button'); replyBox.type = 'button'; replyBox.className = 'bubble-reply-preview'; const replyAuthor = document.createElement('div'); replyAuthor.className = 'bubble-reply-preview-author'; replyAuthor.textContent = String(replyTarget?.from === 'out' ? state.session.login : parsedText.replyRef.fromLogin); const replyText = document.createElement('div'); replyText.className = 'bubble-reply-preview-text'; replyText.textContent = truncatePreviewText(replyParsed.displayText || '', 96); replyBox.append(replyAuthor, replyText); replyBox.addEventListener('click', (event) => { event.stopPropagation(); const targetNode = list.querySelector(`[data-base-key="${CSS.escape(String(replyTarget?.baseKey || ''))}"]`); if (targetNode?.scrollIntoView) { targetNode.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); } }); bubble.append(replyBox); } } if (!parsedText.callSummary) { 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); }); bubble.dataset.baseKey = String(msg?.baseKey || ''); list.append(bubble); }); if (scrollMode === 'unread' && !scrollToUnreadSeparator(list)) { scrollToLatestMessage(list); } else if (scrollMode === 'latest') { scrollToLatestMessage(list); } if (markAsRead) { markChatRead(chatId); } } function preserveComposerSelection(input, callback) { if (!input || typeof callback !== 'function') { if (typeof callback === 'function') callback(); return; } const wasFocused = document.activeElement === input; const start = Number(input.selectionStart ?? input.value.length); const end = Number(input.selectionEnd ?? input.value.length); callback(); if (!wasFocused) return; try { input.focus({ preventScroll: true }); } catch { input.focus(); } try { input.setSelectionRange(start, end); } catch { // ignore } } function setChatKeyboardOpen(isOpen) { document.body.classList.toggle('chat-keyboard-open', !!isOpen); } export function render({ navigate, route }) { const routeChatId = route.params.chatId || 'u1'; const chatId = normalizeDmChatId(routeChatId) || 'u1'; const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || { id: chatId, name: String(routeChatId || chatId), initials: (String(routeChatId || chatId)[0] || '?').toUpperCase(), }; const screen = document.createElement('section'); screen.className = 'stack dm-screen dm-chat-screen'; const isSpeechToTextReady = isSpeechToTextConfigured(state.entrySettings); const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings); const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase()); const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread); const handleReadAloud = async (msg) => { if (!isTextToSpeechConfigured(state.entrySettings)) { showTtsMissingConfigDialog(navigate); return; } const parsedText = parseDmTechBlocks(String(msg?.text || '')); await speakTextBySettings(String(parsedText.displayText || ''), state.entrySettings); }; const handleStartCall = 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 }); } }; const clearConversationHistory = async () => { const result = await authService.deleteConversation({ login: state.session.login, toLogin: chatId, storagePwd: state.session.storagePwdInMemory, deleteByRecipient: true, }); const parsed = authService.parseSignedMessageBlob(String(result?.localBlobB64 || '').trim()); deleteConversationMessagesBefore(chatId, Number(parsed?.timeMs || 0)); addSignedMessageToChat({ chatId, messageKey: String(parsed?.messageKey || ''), baseKey: String(parsed?.baseKey || ''), from: 'out', text: CONVERSATION_CLEAR_NOTICE_TEXT, messageType: Number(parsed?.messageType || 8), unread: false, rawBlobB64: String(result?.localBlobB64 || ''), }); renderLog(log, chatId, { onOpenActions: handleOpenActions, markAsRead: false, scrollMode: 'latest', }); notifyUnreadStateUpdated(); }; 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: '☎', title: 'Позвонить', ariaLabel: 'Позвонить', className: 'chat-header-icon-btn chat-header-call-btn', onClick: handleStartCall, }, { label: '⋯', title: 'Действия чата', ariaLabel: 'Открыть меню действий чата', className: 'chat-header-icon-btn chat-header-menu-btn', onClick: (event) => { openChatActionsMenu({ anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0), anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0), onCall: handleStartCall, onClearHistory: async () => { openChatConfirmModal({ title: 'Очистить историю?', text: `Добавить техническое сообщение очистки истории переписки с ${contact.name}?`, confirmLabel: 'Очистить', confirmKind: 'danger', onConfirm: async () => { try { await clearConversationHistory(); showToast('История чата очищена', { timeoutMs: 1200 }); } catch (error) { showToast(`Не удалось очистить историю: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 }); } }, }); }, onDeleteChat: async () => { openDeleteChatConfirmModal({ contactName: contact.name, onConfirm: async ({ deleteHistory }) => { try { if (deleteHistory) { await clearConversationHistory(); } await authService.setUserRelation({ login: state.session.login, toLogin: chatId, kind: 'contact', enabled: false, storagePwd: state.session.storagePwdInMemory, }); const contactsPayload = await authService.listContacts(); setContacts(contactsPayload?.contacts || []); notifyUnreadStateUpdated(); showToast('Чат удалён из контактов', { timeoutMs: 1200 }); navigate('messages-list'); } catch (error) { showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 }); } }, }); }, }); }, }, ], }) ); 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 = `
${isSpeechToTextReady ? '' : ''}
`; const input = form.elements.message; const emojiSlot = form.querySelector('#chat-emoji-picker-slot'); const emojiToggle = form.querySelector('#chat-emoji-toggle'); 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; let activeReply = null; let inputFocused = false; let emojiPickerOpen = false; let emojiSelection = null; const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0); const syncKeyboardUi = () => { const viewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0); const viewportShrunk = baseViewportHeight - viewportHeight > 120; setChatKeyboardOpen(inputFocused && viewportShrunk); if (inputFocused) { window.requestAnimationFrame(() => scrollToLatestMessage(log)); } }; const rememberEmojiSelection = () => { if (!input) return; const valueLength = String(input.value || '').length; const start = Math.max(0, Math.min(valueLength, Number(input.selectionStart ?? valueLength))); const end = Math.max(start, Math.min(valueLength, Number(input.selectionEnd ?? start))); emojiSelection = { start, end }; }; const closeEmojiPicker = () => { if (!emojiPickerOpen) return; emojiPickerOpen = false; stopAllTelegramEmojiAnimations(); emojiSlot.hidden = true; emojiToggle?.setAttribute('aria-expanded', 'false'); }; const insertEmoji = (emoji) => { if (!input) return; const value = String(input.value || ''); const start = emojiSelection?.start ?? Number(input.selectionStart ?? value.length); const end = emojiSelection?.end ?? Number(input.selectionEnd ?? value.length); input.value = `${value.slice(0, start)}${emoji}${value.slice(end)}`; const nextPosition = start + emoji.length; emojiSelection = { start: nextPosition, end: nextPosition }; input.focus(); try { input.setSelectionRange(nextPosition, nextPosition); } catch { // Ignore browsers that do not expose textarea selection during focus changes. } autoResizeComposer(input); }; if (emojiSlot) { emojiSlot.append(createEmojiPicker({ onSelect: insertEmoji })); } const syncEditBanner = () => { if (!editBanner || !editBannerText) return; if (!activeEdit && !activeReply) { editBanner.hidden = true; editBannerText.textContent = ''; return; } editBanner.hidden = false; if (activeEdit) { editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`; return; } editBannerText.textContent = `Ответить\n${truncatePreviewText(activeReply?.previewText || '', 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 cancelReplyMode = ({ restoreDraft = true } = {}) => { if (!activeReply) return; activeReply = null; syncEditBanner(); if (restoreDraft && input) { autoResizeComposer(input); focusInputToEnd(); } }; const startEditMode = (msg) => { const base = parseBaseKey(msg?.baseKey); if (!base || msg?.from !== 'out') return; const parsed = parseDmTechBlocks(String(msg?.text || '')); const currentDraft = String(input?.value || ''); activeReply = null; activeEdit = { messageKey: String(msg?.messageKey || ''), originalText: String(parsed?.displayText || ''), prefixText: String(parsed?.prefixText || ''), draftBeforeEdit: activeEdit ? String(activeEdit.draftBeforeEdit || '') : currentDraft, timeMs: base.timeMs, nonce: base.nonce, }; syncEditBanner(); if (input) { input.value = String(parsed?.visibleText || ''); autoResizeComposer(input); focusInputToEnd(); } }; const startReplyMode = (msg) => { const base = parseBaseKey(msg?.baseKey); if (!base) return; const parsed = parseDmTechBlocks(String(msg?.text || '')); activeEdit = null; activeReply = { baseKey: String(msg?.baseKey || ''), fromLogin: base.fromLogin, toLogin: base.toLogin, previewText: String(parsed?.displayText || parsed?.visibleText || ''), }; syncEditBanner(); focusInputToEnd(); }; const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => { if (!localOutgoingBlobB64) return; try { const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64); let text = ''; if (Number(parsed?.messageType || 0) === 1 || Number(parsed?.messageType || 0) === 2) { const decrypted = await authService.decryptSignedMessageContent({ parsed, login: state.session.login, storagePwd: state.session.storagePwdInMemory, }); text = String(decrypted?.text || ''); } addSignedMessageToChat({ chatId, messageKey: fallbackMessageKey || parsed?.messageKey || '', baseKey: fallbackBaseKey || parsed?.baseKey || '', from: 'out', text, messageType: Number(parsed?.messageType || 2), unread: false, rawBlobB64: localOutgoingBlobB64, revisionTimeMs: Number(parsed?.revisionTimeMs || 0), deleted: Boolean(parsed?.deleted), }); return true; } catch { // ignore local parse failure; server backlog/realtime will reconcile later return false; } }; const notifyUnreadStateUpdated = () => { window.dispatchEvent(new CustomEvent('shine-unread-state-updated', { detail: { chatId }, })); }; 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(), deleteByRecipient: msg?.from === 'in', }); addSignedMessageToChat({ chatId, messageKey: String(msg?.messageKey || ''), baseKey: String(msg?.baseKey || ''), deleted: true, }); if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) { cancelEditMode({ restoreDraft: true }); } renderLog(log, chatId, { onOpenActions: handleOpenActions }); }; const sendTextMessage = async (rawText) => { const safeText = sanitizeUserDmTextForSend(String(rawText || '')); const text = safeText.trim(); if (!text) return; const editing = activeEdit; const replying = !editing ? activeReply : null; const finalText = editing ? `${String(editing?.prefixText || '')}${text}` : `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`; const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text); renderLog(log, chatId, { onOpenActions: handleOpenActions }); scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); try { let result; if (editing) { result = await authService.sendDirectMessageRevision({ login: state.session.login, toLogin: chatId, text: finalText, 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: finalText, storagePwd: state.session.storagePwdInMemory, }); markOutgoingSent(tempId, { messageKey: result?.outgoingKey || '', baseKey: result?.baseKey || result?.localBaseKey || '', }); } const localRevisionApplied = await applyLocalRevision({ localOutgoingBlobB64: result?.localOutgoingBlobB64 || '', fallbackMessageKey: result?.outgoingKey || '', fallbackBaseKey: result?.baseKey || result?.localBaseKey || '', }); if (editing) { cancelEditMode({ restoreDraft: true }); } else if (replying) { cancelReplyMode({ restoreDraft: false }); } renderLog(log, chatId, { onOpenActions: handleOpenActions }); if (localRevisionApplied) { notifyUnreadStateUpdated(); } scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); window.requestAnimationFrame(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true })); window.setTimeout(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }), 220); if (input) { try { input.focus({ preventScroll: true }); } catch { input.focus(); } } 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), replyToBaseKey: replying?.baseKey || '', }, }); } 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) => { const parsed = parseDmTechBlocks(String(msg?.text || '')); openMessageActionsMenu({ anchorX: Number(event?.clientX || 0), anchorY: Number(event?.clientY || 0), messageText: parsed?.displayText || msg?.text || '', canReply: true, showReadAloud: isTextToSpeechReady, canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary, canDelete: ( (msg?.from === 'out' && Number(msg?.messageType || 0) === 2) || (msg?.from === 'in' && Number(msg?.messageType || 0) === 1) ), onReply: async () => { startReplyMode(msg); }, 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', () => { if (activeEdit) { cancelEditMode({ restoreDraft: true }); return; } cancelReplyMode({ restoreDraft: true }); }); autoResizeComposer(input); input?.addEventListener('input', () => { rememberEmojiSelection(); autoResizeComposer(input); }); input?.addEventListener('select', rememberEmojiSelection); input?.addEventListener('keyup', rememberEmojiSelection); input?.addEventListener('click', rememberEmojiSelection); input?.addEventListener('focus', () => { rememberEmojiSelection(); inputFocused = true; syncKeyboardUi(); scrollToLatestMessage(log); }); input?.addEventListener('blur', () => { inputFocused = false; setChatKeyboardOpen(false); }); emojiToggle?.setAttribute('aria-expanded', 'false'); emojiToggle?.addEventListener('pointerdown', (event) => { rememberEmojiSelection(); event.preventDefault(); }); emojiToggle?.addEventListener('click', () => { emojiPickerOpen = !emojiPickerOpen; emojiSlot.hidden = !emojiPickerOpen; emojiToggle.setAttribute('aria-expanded', String(emojiPickerOpen)); }); screen.addEventListener('pointerdown', (event) => { if (!emojiPickerOpen || form.contains(event.target)) return; closeEmojiPicker(); }); input?.addEventListener('keydown', async (event) => { if (event.key !== 'Enter') return; window.requestAnimationFrame(() => autoResizeComposer(input)); }); 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.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => { event.preventDefault(); try { input?.focus({ preventScroll: true }); } catch { input?.focus(); } }); form.addEventListener('submit', async (event) => { event.preventDefault(); const text = String(input.value || '').trim(); if (!text) return; input.value = ''; autoResizeComposer(input); closeEmojiPicker(); await sendTextMessage(text); focusInputToEnd(); }); const handleIncomingChatRefresh = async (event) => { const updatedChatId = normalizeDmChatId(event?.detail?.chatId); if (updatedChatId !== chatId) return; preserveComposerSelection(input, () => { renderLog(log, chatId, { onOpenActions: handleOpenActions, scrollMode: 'latest' }); }); window.requestAnimationFrame(() => scrollToLatestMessage(log)); void sendReadReceiptsForVisible(chatId); }; window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.visualViewport?.addEventListener('resize', syncKeyboardUi); window.addEventListener('resize', syncKeyboardUi); wrap.append(log, form); screen.append(wrap); renderLog(log, chatId, { onOpenActions: handleOpenActions, markAsRead: false, scrollMode: hasUnreadIncoming ? 'unread' : 'latest', }); if (hasUnreadIncoming) { window.requestAnimationFrame(() => scrollToUnreadSeparator(log)); window.setTimeout(() => scrollToUnreadSeparator(log), 180); } else { window.requestAnimationFrame(() => scrollToLatestMessage(log)); window.setTimeout(() => scrollToLatestMessage(log), 180); } window.setTimeout(() => { if (markChatRead(chatId) > 0) { notifyUnreadStateUpdated(); } }, 220); void sendReadReceiptsForVisible(chatId); screen.cleanup = () => { setChatKeyboardOpen(false); stopAllTelegramEmojiAnimations(); window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.visualViewport?.removeEventListener('resize', syncKeyboardUi); window.removeEventListener('resize', syncKeyboardUi); }; 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, }); 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' }, }); } } }