import { createTopBar } from '../components/topbar.js'; import { createDropdownMenu } from '../components/dropdown-menu.js'; import { renderUserAvatar } from '../components/avatar-image.js'; import { createOverflowDots } from '../components/overflow-dots.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, createTwemojiAsset, appendTwemojiText, hasTwemojiAsset, stopAllTwemojiAnimations, } from '../components/emoji-picker.js?v=202607152130'; import { 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'; import { loadProfileSnapshot } from '../services/user-profile-params.js'; import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js'; export const pageMeta = { id: 'chat-view', title: 'Чат', shellMode: { topFade: true, bottomFade: true, bottomFadeAnchor: 'composer', contentUnderTopbar: true, contentUnderBottom: true, scrollContainer: 'nested', }, }; const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места'; function menuIconSvg(name) { const paths = { reply: '', copy: '', read: '', edit: '', delete: '', call: '', video: '', clear: '', }; return ``; } function normalizeChatRelationType(value) { const clean = String(value || '').trim().toLowerCase(); if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean; return 'none'; } function chatRelationLabel(value) { switch (normalizeChatRelationType(value)) { case 'close_friend': return 'Близкий друг'; case 'friend': return 'Друг'; case 'contact': return 'Контакт'; default: return 'Не в контактах'; } } function createChatHeaderParts(login, navigate) { const cleanLogin = String(login || '').trim() || 'unknown'; let currentPeer = { login: cleanLogin, firstName: '', lastName: '', avatar: null, relationType: 'none', }; const identityButton = document.createElement('button'); identityButton.type = 'button'; identityButton.className = 'chat-header-peer-btn'; identityButton.title = `Меню ${cleanLogin}`; identityButton.setAttribute('aria-label', `Открыть меню пользователя ${cleanLogin}`); const avatarSlot = document.createElement('span'); avatarSlot.className = 'chat-header-avatar-slot'; const textWrap = document.createElement('span'); textWrap.className = 'chat-header-peer-text'; const nameEl = document.createElement('span'); nameEl.className = 'chat-header-peer-name'; const metaEl = document.createElement('span'); metaEl.className = 'chat-header-peer-meta'; textWrap.append(nameEl, metaEl); identityButton.append(avatarSlot, textWrap); const renderPeer = () => { const firstName = String(currentPeer?.firstName || '').trim(); const lastName = String(currentPeer?.lastName || '').trim(); const fullName = [firstName, lastName].filter(Boolean).join(' '); nameEl.textContent = fullName || cleanLogin; metaEl.textContent = `${cleanLogin} · ${chatRelationLabel(currentPeer?.relationType)}`; const avatar = renderUserAvatar({ login: cleanLogin, firstName, lastName, avatar: currentPeer?.avatar?.ar ? { ar: String(currentPeer.avatar.ar || '').trim(), sha256Hex: String(currentPeer.avatar.sha256Hex || '').trim().toLowerCase(), } : null, size: 'md', className: 'chat-header-avatar', title: cleanLogin, }); avatarSlot.replaceChildren(avatar); }; const updatePeer = (peer) => { if (!peer || typeof peer !== 'object') return; currentPeer = { ...currentPeer, ...peer, login: String(peer.login || cleanLogin).trim() || cleanLogin, relationType: normalizeChatRelationType(peer.relationType), }; renderPeer(); }; renderPeer(); const identityMenu = createDropdownMenu({ transparent: true, anchorEl: identityButton, placement: 'bottom-start', minWidth: 210, items: [ { label: 'Показать связи', iconSrc: '/assets/SHiNE_connections_blue.svg', action: () => navigate(makeProfileLinksRoute(cleanLogin)), }, { label: 'Показать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate(makeProfileRoute(cleanLogin)), }, ], }); return { center: identityButton, updatePeer, getPeer: () => ({ ...currentPeer }), cleanup: () => identityMenu.destroy(), }; } 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 = '', relationType = 'none', onConfirm }) { const root = document.getElementById('modal-root'); if (!root) return; const relation = normalizeChatRelationType(relationType); const isCloseFriend = relation === 'close_friend'; const isFriend = relation === 'friend'; const isProtectedRelation = isCloseFriend || isFriend; const relationName = isCloseFriend ? 'близких друзей' : 'друзей'; const safeName = String(contactName || '').trim() || 'этого пользователя'; 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 = isProtectedRelation ? true : Boolean(root.querySelector('#chat-delete-chat-history')?.checked); close(); if (typeof onConfirm === 'function') { await onConfirm({ deleteHistory, removeRelation: isProtectedRelation }); } }); } function openMessageActionsMenu({ anchorX = 0, anchorY = 0, messageText = '', infoText = '', 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 showTtsMissingConfigDialog() { const root = document.getElementById('modal-root'); if (!root) return; root.innerHTML = ` `; const close = () => { root.innerHTML = ''; }; root.querySelector('#chat-tts-ok')?.addEventListener('click', close); } function autoResizeComposer(textarea) { if (!textarea) return; textarea.style.height = 'auto'; textarea.style.height = `${Math.min(180, Math.max(42, textarea.scrollHeight))}px`; } function createHeaderPhoneHandsetIcon() { const ns = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(ns, 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('class', 'header-icon-svg header-icon-svg--phone'); const path = document.createElementNS(ns, 'path'); path.setAttribute('d', 'M7.62 10.79a15.1 15.1 0 0 0 5.59 5.59l1.87-1.87a1 1 0 0 1 1.02-.24c1.12.37 2.31.57 3.52.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1C10.61 21 3 13.39 3 4a1 1 0 0 1 1-1h3.37a1 1 0 0 1 1 1c0 1.21.2 2.4.57 3.52a1 1 0 0 1-.24 1.02l-1.08 1.27Z'); path.setAttribute('fill', 'currentColor'); svg.append(path); return svg; } 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 resolveEffectiveReadState(messages, msg) { if (msg?.from !== 'out') return { isRead: false, readAtMs: 0 }; const explicitReadAtMs = Number(msg?.readAtMs || 0); if (Number.isFinite(explicitReadAtMs) && explicitReadAtMs > 0) { return { isRead: true, readAtMs: explicitReadAtMs }; } if (msg?.secondTick) { return { isRead: true, readAtMs: 0 }; } const messageTimeMs = resolveMessageTimeMs(msg); const hasNewerRead = (messages || []).some((row) => { if (row?.from !== 'out') return false; if (resolveMessageTimeMs(row) <= messageTimeMs) return false; return Boolean(Number(row?.readAtMs || 0) > 0 || row?.secondTick); }); return { isRead: hasNewerRead, readAtMs: 0 }; } function resolveDeliveryStatus(messages, msg) { if (msg?.from !== 'out') return ''; if (resolveEffectiveReadState(messages, msg).isRead) return '✓✓'; const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase(); if (deliveryState === 'delivered') return '✓'; if (deliveryState === 'failed') return '!'; if (deliveryState === 'accepted' || msg?.firstTick) return '✓'; return '…'; } function resolveDeliveryTone(messages, msg) { if (resolveEffectiveReadState(messages, msg).isRead) return 'read'; const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase(); if (deliveryState === 'delivered') return 'delivered'; if (deliveryState === 'failed') return 'failed'; return 'accepted'; } function resolveDeliveryNote(msg) { if (msg?.from !== 'out') return ''; const deliveryState = String(msg?.deliveryState || '').trim().toLowerCase(); if (deliveryState === 'failed') 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 && hasTwemojiAsset(emoji)) { textNode.classList.add('bubble-text--twemoji-only'); textNode.append(createTwemojiAsset(emoji, { className: 'chat-twemoji' })); return; } if (appendTwemojiText(textNode, text, { className: 'chat-twemoji chat-twemoji--inline' })) { textNode.classList.add('bubble-text--twemoji-inline'); } } function isTwemojiOnlyMessage(messageText) { const emoji = String(messageText || '').trim(); return Boolean(emoji && String(messageText || '') === emoji && hasTwemojiAsset(emoji)); } 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', showUnreadSeparator = true, unreadSeparatorMessageKey = '', } = {}, ) { list.innerHTML = ''; const messages = getChatMessages(chatId); let unreadSeparatorInserted = false; const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim(); messages.forEach((msg) => { const isUnreadBoundary = showUnreadSeparator && !unreadSeparatorInserted && separatorMessageKey && String(msg?.messageKey || '').trim() === separatorMessageKey; if (isUnreadBoundary) { 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}` : ''}`; const emojiOnly = !parsedText.callSummary && !parsedText.replyRef && isTwemojiOnlyMessage(parsedText.displayText || ''); if (emojiOnly) { bubble.classList.add('bubble--emoji-only'); } 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.classList.add(msg?.from === 'out' ? 'bubble-call-icon--outgoing' : 'bubble-call-icon--incoming'); const callIconPhone = document.createElement('span'); callIconPhone.className = 'bubble-call-icon-phone'; callIconPhone.textContent = '✆'; const callIconArrow = document.createElement('span'); callIconArrow.className = 'bubble-call-icon-arrow'; callIconArrow.textContent = msg?.from === 'out' ? '↑' : '↓'; callIcon.append(callIconPhone, callIconArrow); const callTitle = document.createElement('span'); callTitle.className = 'bubble-call-title'; callTitle.textContent = 'Звонок'; const callMetaLine = document.createElement('span'); callMetaLine.className = 'bubble-call-line'; callMetaLine.classList.add( parsedText.callSummary.status === 'completed' ? 'bubble-call-line--success' : 'bubble-call-line--plain', ); const callStatus = buildCallStatusText(parsedText.callSummary); callMetaLine.textContent = callStatus; callHead.append(callIcon, callTitle, callMetaLine); callCard.append(callHead); 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 = 'ui-button 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(messages, msg); if (status) { const statusNode = document.createElement('span'); statusNode.className = `bubble-status bubble-status--${resolveDeliveryTone(messages, msg)}`; statusNode.textContent = status; metaNode.append(statusNode); } bubble.append(metaNode); const deliveryNote = resolveDeliveryNote(msg); if (deliveryNote) { const deliveryNoteNode = document.createElement('div'); deliveryNoteNode.className = `bubble-delivery-note${String(msg?.deliveryState || '') === 'failed' ? ' bubble-delivery-note--failed' : ''}`; deliveryNoteNode.textContent = deliveryNote; bubble.append(deliveryNoteNode); } 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 } } async function mergeDirectMessagesPage(chatId, payloadMessages) { const items = Array.isArray(payloadMessages) ? [...payloadMessages] : []; items.sort((a, b) => Number(a?.timeMs || 0) - Number(b?.timeMs || 0)); for (const item of items) { const blobB64 = String(item?.blobB64 || '').trim(); const messageKey = String(item?.messageKey || '').trim(); if (!blobB64 || !messageKey) continue; try { const parsed = authService.parseSignedMessageBlob(blobB64); let text = ''; try { const decrypted = await authService.decryptSignedMessageContent({ parsed, login: state.session.login, storagePwd: state.session.storagePwdInMemory, }); text = String(decrypted?.text || ''); } catch (error) { text = `Нерасшифрованное сообщение (${error?.message || 'decrypt failed'})`; } addSignedMessageToChat({ chatId, messageKey, baseKey: String(item?.baseKey || parsed?.baseKey || ''), from: Number(parsed?.messageType || 0) === 2 ? 'out' : 'in', text, messageType: Number(parsed?.messageType || item?.messageType || 0), unread: false, readAtMs: Number(item?.readAtMs || 0), rawBlobB64: blobB64, revisionTimeMs: Number(item?.revisionTimeMs || parsed?.revisionTimeMs || 0), deliveryState: String(item?.deliveryState || ''), }); } catch (error) { addAppLogEntry({ level: 'warn', source: 'dm-history', message: 'Не удалось обработать сообщение из истории DM', details: { chatId, messageKey, error: error?.message || 'unknown' }, }); } } } export function render({ navigate, route, chrome }) { 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 isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings); const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase()); let peerRelationType = isKnownContact ? 'contact' : 'none'; const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread); let historyHasMore = true; let historyLoading = false; let historyNextBeforeTimeMs = 0; let historyNextBeforeMessageKey = ''; let historyBootstrapped = false; let boundScrollContainer = null; let unreadSeparatorVisible = hasUnreadIncoming; let unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || ''; const renderChatLog = ({ scrollMode = 'latest', markAsRead = true } = {}) => { renderLog(log, chatId, { onOpenActions: handleOpenActions, markAsRead, scrollMode, showUnreadSeparator: unreadSeparatorVisible, unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey, }); }; const hideUnreadSeparator = ({ rerender = true } = {}) => { if (!unreadSeparatorVisible) return; unreadSeparatorVisible = false; if (rerender) { renderChatLog({ scrollMode: 'latest', markAsRead: false }); } }; const handleReadAloud = async (msg) => { if (!isTextToSpeechConfigured(state.entrySettings)) { showTtsMissingConfigDialog(); return; } const parsedText = parseDmTechBlocks(String(msg?.text || '')); await speakTextBySettings(String(parsedText.displayText || ''), state.entrySettings); }; const handleStartCall = async (mode = 'audio') => { try { await startOutgoingCall(chatId, { mode }); renderChatLog({ scrollMode: 'latest', markAsRead: false }); } catch (e) { addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, { from: 'out', kind: 'call-tech', }); renderChatLog({ scrollMode: 'latest', markAsRead: false }); } }; 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 || ''), }); renderChatLog({ scrollMode: 'latest', markAsRead: false }); notifyUnreadStateUpdated(); }; const addPeerToContacts = async () => { 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, }); peerRelationType = 'contact'; chatHeaderParts?.updatePeer?.({ relationType: 'contact' }); const contactsPayload = await authService.listContacts(); setContacts(contactsPayload?.contacts || contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin) || []); notifyUnreadStateUpdated(); showToast('Добавлено в контакты', { timeoutMs: 1200 }); }; const wrap = document.createElement('div'); wrap.className = 'chat-wrap dm-chat-wrap'; const historyLoader = document.createElement('div'); historyLoader.className = 'dm-history-loader'; historyLoader.hidden = true; historyLoader.innerHTML = `
Загрузка истории…
`; const log = document.createElement('div'); log.className = 'messages-log dm-messages-log'; const chatHeaderParts = createChatHeaderParts(chatId, navigate); const chatHeader = createTopBar({ center: chatHeaderParts.center, back: { label: '←', onClick: () => navigate('messages-list') }, actions: [ { title: 'Позвонить', ariaLabel: 'Позвонить', className: 'chat-header-icon-btn chat-header-call-btn', iconNode: createHeaderPhoneHandsetIcon(), onClick: () => handleStartCall('audio'), }, { iconNode: createOverflowDots(), title: 'Действия чата', ariaLabel: 'Открыть меню действий чата', className: 'chat-header-icon-btn chat-header-menu-btn', menu: { minWidth: 230, items: () => [ { label: 'Звонок', iconHtml: menuIconSvg('call'), action: () => handleStartCall('audio') }, { label: 'Видеозвонок', iconHtml: menuIconSvg('video'), action: () => handleStartCall('video') }, normalizeChatRelationType(peerRelationType) === 'none' ? { label: 'Добавить в контакты', iconHtml: '', action: async () => { try { await addPeerToContacts(); } catch (error) { showToast(`Не удалось добавить в контакты: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 }); } }, } : null, { label: 'Очистить историю', iconHtml: menuIconSvg('clear'), action: () => 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 }); } }, }), }, { label: 'Удалить чат', iconHtml: menuIconSvg('delete'), danger: true, action: () => { const relationBeforeDelete = normalizeChatRelationType(peerRelationType); openDeleteChatConfirmModal({ contactName: contact.name, relationType: relationBeforeDelete, onConfirm: async ({ deleteHistory }) => { try { if (deleteHistory) await clearConversationHistory(); const relationKinds = relationBeforeDelete === 'close_friend' || relationBeforeDelete === 'friend' ? ['close_friend', 'friend', 'contact'] : ['contact']; for (const kind of relationKinds) { await authService.setUserRelation({ login: state.session.login, toLogin: chatId, kind, enabled: false, storagePwd: state.session.storagePwdInMemory, }); } const contactsPayload = await authService.listContacts(); setContacts( contactsPayload?.contacts || contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin) || [], ); notifyUnreadStateUpdated(); showToast('Чат удалён', { timeoutMs: 1200 }); navigate('messages-list'); } catch (error) { showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 }); } }, }); }, }, ].filter(Boolean), }, }, ], }); chatHeader.addCleanup(chatHeaderParts.cleanup); chrome?.setTopbar(chatHeader); const form = document.createElement('form'); form.className = 'chat-input dm-chat-input'; form.innerHTML = `
`; 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 setHistoryLoadingState = (isLoading) => { historyLoader.hidden = !isLoading; historyLoader.classList.toggle('is-visible', !!isLoading); }; const syncComposerLayout = () => { const height = Number.parseFloat(input?.style.height || '0'); form.classList.toggle('dm-chat-input--multiline', height > 52); }; 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; stopAllTwemojiAnimations(); emojiSlot.hidden = true; emojiToggle?.setAttribute('aria-expanded', 'false'); }; // На сенсорных устройствах фокус в textarea поднимает клавиатуру поверх пикера — // поэтому при вставке эмодзи фокусируем поле только там, где есть «точный» указатель. const isFinePointer = window.matchMedia?.('(hover: hover) and (pointer: fine)')?.matches ?? true; 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 }; if (isFinePointer) { input.focus(); try { input.setSelectionRange(nextPosition, nextPosition); } catch { // Ignore browsers that do not expose textarea selection during focus changes. } } autoResizeComposer(input); syncComposerLayout(); }; 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); syncComposerLayout(); focusInputToEnd(); } }; const cancelReplyMode = ({ restoreDraft = true } = {}) => { if (!activeReply) return; activeReply = null; syncEditBanner(); if (restoreDraft && input) { autoResizeComposer(input); syncComposerLayout(); 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); syncComposerLayout(); 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 = '', deliveryState = '', }) => { 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), deliveryState, 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 }); } renderChatLog({ scrollMode: 'latest', markAsRead: false }); }; 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); renderChatLog({ scrollMode: 'latest', markAsRead: false }); 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 || '', deliveryState: result?.deliveryState || 'accepted', }); } const localRevisionApplied = await applyLocalRevision({ localOutgoingBlobB64: result?.localOutgoingBlobB64 || '', fallbackMessageKey: result?.outgoingKey || '', fallbackBaseKey: result?.baseKey || result?.localBaseKey || '', deliveryState: result?.deliveryState || 'accepted', }); // In a DM the "Новые сообщения" divider stays fixed for the current open // chat session and disappears only after a successfully sent NEW message. // Editing an existing message must not clear the divider. if (!editing) { hideUnreadSeparator({ rerender: false }); } if (editing) { cancelEditMode({ restoreDraft: true }); } else if (replying) { cancelReplyMode({ restoreDraft: false }); } renderChatLog({ scrollMode: 'latest', markAsRead: false }); 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); syncComposerLayout(); focusInputToEnd(); } addChatMessage(chatId, `${activeEdit ? 'Ошибка изменения' : 'Ошибка отправки'}: ${e.message || 'unknown'}`); addAppLogEntry({ level: 'warn', source: 'outgoing-dm', message: activeEdit ? 'Ошибка редактирования личного сообщения' : 'Ошибка отправки личного сообщения', details: { toLogin: chatId, error: e?.message || 'unknown', }, }); renderChatLog({ scrollMode: 'latest', markAsRead: false }); } }; const handleOpenActions = (msg, event) => { const parsed = parseDmTechBlocks(String(msg?.text || '')); const readState = resolveEffectiveReadState(getChatMessages(chatId), msg); openMessageActionsMenu({ anchorX: Number(event?.clientX || 0), anchorY: Number(event?.clientY || 0), messageText: parsed?.displayText || msg?.text || '', infoText: (msg?.from === 'out' && readState.readAtMs > 0) ? `Прочитано: ${formatMessageTime(readState.readAtMs)}` : '', 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 }); } }, }); }, }); }; const loadHistoryPage = async ({ preserveScroll = false } = {}) => { if (historyLoading) return; if (historyBootstrapped && !historyHasMore) return; historyLoading = true; setHistoryLoadingState(true); const scrollContainer = boundScrollContainer || log.closest('.screen-content') || wrap.parentElement || wrap; const prevScrollTop = Number(scrollContainer?.scrollTop || 0); const prevScrollHeight = Number(scrollContainer?.scrollHeight || 0); try { const payload = await authService.getDirectMessages({ peerLogin: chatId, limit: 50, beforeTimeMs: historyBootstrapped ? historyNextBeforeTimeMs : 0, beforeMessageKey: historyBootstrapped ? historyNextBeforeMessageKey : '', }); if (payload?.peer) { peerRelationType = normalizeChatRelationType(payload.peer.relationType); chatHeaderParts.updatePeer(payload.peer); } await mergeDirectMessagesPage(chatId, payload?.messages || []); historyHasMore = Boolean(payload?.hasMore); historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0); historyNextBeforeMessageKey = String(payload?.nextBeforeMessageKey || '').trim(); historyBootstrapped = true; renderChatLog({ markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' }); if (preserveScroll) { window.requestAnimationFrame(() => { const nextHeight = Number(scrollContainer?.scrollHeight || 0); scrollContainer.scrollTop = Math.max(0, prevScrollTop + (nextHeight - prevScrollHeight)); }); } else { window.requestAnimationFrame(() => scrollToLatestMessage(log)); } void sendReadReceiptsForVisible(chatId); } catch (error) { addAppLogEntry({ level: 'warn', source: 'dm-history', message: 'Не удалось загрузить историю DM', details: { chatId, error: error?.message || 'unknown' }, }); } finally { historyLoading = false; setHistoryLoadingState(false); } }; const handleHistoryScroll = () => { const scrollContainer = boundScrollContainer || log.closest('.screen-content') || wrap.parentElement || wrap; if (!scrollContainer) return; if (Number(scrollContainer.scrollTop || 0) > 120) return; if (!historyBootstrapped || !historyHasMore || historyLoading) return; void loadHistoryPage({ preserveScroll: true }); }; editCancelBtn?.addEventListener('click', () => { if (activeEdit) { cancelEditMode({ restoreDraft: true }); return; } cancelReplyMode({ restoreDraft: true }); }); autoResizeComposer(input); syncComposerLayout(); input?.addEventListener('input', () => { rememberEmojiSelection(); autoResizeComposer(input); syncComposerLayout(); }); input?.addEventListener('select', rememberEmojiSelection); input?.addEventListener('keyup', rememberEmojiSelection); input?.addEventListener('click', rememberEmojiSelection); input?.addEventListener('focus', () => { rememberEmojiSelection(); inputFocused = true; window.requestAnimationFrame(() => { if (inputFocused) scrollToLatestMessage(log); }); }); input?.addEventListener('blur', () => { inputFocused = 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)); // Либо клавиатура, либо пикер. На тач-устройствах при // открытии пикера прячем клавиатуру, чтобы поле ввода оставалось видно. if (emojiPickerOpen && !isFinePointer) input?.blur(); }); screen.addEventListener('pointerdown', (event) => { if (!emojiPickerOpen || form.contains(event.target)) return; closeEmojiPicker(); }); 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); syncComposerLayout(); closeEmojiPicker(); await sendTextMessage(text); focusInputToEnd(); }); const handleIncomingChatRefresh = async (event) => { const updatedChatId = normalizeDmChatId(event?.detail?.chatId); if (updatedChatId !== chatId) return; if (Number(event?.detail?.messageType || 0) === 1) { if (!unreadSeparatorVisible) { unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey; unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey); } } preserveComposerSelection(input, () => { renderChatLog({ scrollMode: 'latest' }); }); window.requestAnimationFrame(() => scrollToLatestMessage(log)); void sendReadReceiptsForVisible(chatId); }; const handleDeliveryRefresh = () => { preserveComposerSelection(input, () => { renderChatLog({ scrollMode: 'preserve', markAsRead: false }); }); }; window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.addEventListener('shine-dm-delivery-updated', handleDeliveryRefresh); wrap.append(historyLoader, log); screen.append(wrap); chrome?.setComposer(form); renderLog(log, chatId, { onOpenActions: handleOpenActions, markAsRead: false, scrollMode: hasUnreadIncoming ? 'unread' : 'latest', showUnreadSeparator: unreadSeparatorVisible, unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey, }); window.setTimeout(() => { if (markChatRead(chatId) > 0) { notifyUnreadStateUpdated(); } }, 220); void sendReadReceiptsForVisible(chatId); window.requestAnimationFrame(() => { boundScrollContainer = wrap; boundScrollContainer?.addEventListener('scroll', handleHistoryScroll, { passive: true }); void loadHistoryPage({ preserveScroll: true }); }); screen.cleanup = () => { hideUnreadSeparator({ rerender: false }); stopAllTwemojiAnimations(); window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh); window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh); boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll); }; 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' }, }); } } }