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 { buildDmFileTechBlock, buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js'; import { isDmFileTransferEnabled } from '../services/feature-settings.js'; import { decryptDmFileToBlob, downloadAndDecryptDmFile, encryptAndUploadDmFile, formatDmFileSize, DM_MAX_ATTACHMENTS_PER_MESSAGE, } from '../services/dm-file-service.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: '', forward: '', 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 buildForwardedDmText(message = {}) { const parsed = parseDmTechBlocks(String(message?.text || '')); const attachments = Array.isArray(parsed?.fileAttachments) ? parsed.fileAttachments : []; const attachmentBlocks = attachments.map((attachment) => buildDmFileTechBlock(attachment)).join(''); const visibleText = sanitizeUserDmTextForSend(String(parsed?.displayText || parsed?.visibleText || '').trim()); if (attachmentBlocks) { const fallback = visibleText || (attachments.length === 1 ? `📎 ${String(attachments[0]?.name || 'file')}` : `📎 Файлы: ${attachments.length}`); return `${attachmentBlocks}${fallback}`; } return visibleText; } function clipForwardPreview(text = '', maxLen = 130) { const normalized = String(text || '').replace(/\s+/g, ' ').trim(); if (normalized.length <= maxLen) return normalized; return `${normalized.slice(0, Math.max(1, maxLen - 1))}…`; } async function openForwardMessageDialog({ message, sourceChatId = '', onConfirm } = {}) { const root = document.getElementById('modal-root'); if (!root) return; const parsed = parseDmTechBlocks(String(message?.text || '')); const preview = clipForwardPreview(parsed?.displayText || parsed?.visibleText || 'Вложение'); root.innerHTML = ` `; const close = () => { root.innerHTML = ''; }; root.querySelector('#chat-forward-close')?.addEventListener('click', close); root.querySelector('#chat-forward-modal')?.addEventListener('click', (event) => { if (event.target?.id === 'chat-forward-modal') close(); }); const previewEl = root.querySelector('#chat-forward-preview'); if (previewEl) { previewEl.textContent = preview || (parsed?.fileAttachments?.length ? 'Вложение' : 'Сообщение'); } const list = root.querySelector('#chat-forward-list'); if (!list) return; try { const payload = await authService.listContacts(); if (!root.querySelector('#chat-forward-modal')) return; const dialogs = Array.isArray(payload?.dialogs) ? payload.dialogs : []; const byLogin = new Map(); dialogs.forEach((dialog) => { const peerLogin = normalizeDmChatId(dialog?.peerLogin); if (!peerLogin) return; byLogin.set(peerLogin.toLowerCase(), { peerLogin, firstName: String(dialog?.firstName || '').trim(), lastName: String(dialog?.lastName || '').trim(), lastMessageTimeMs: Number(dialog?.lastMessageTimeMs || 0), }); }); Object.keys(state.chats || {}).forEach((chatKey) => { const peerLogin = normalizeDmChatId(chatKey); if (!peerLogin) return; const key = peerLogin.toLowerCase(); if (!byLogin.has(key)) byLogin.set(key, { peerLogin, firstName: '', lastName: '', lastMessageTimeMs: 0 }); }); const sourceLogin = normalizeDmChatId(sourceChatId); if (sourceLogin && !byLogin.has(sourceLogin.toLowerCase())) { byLogin.set(sourceLogin.toLowerCase(), { peerLogin: sourceLogin, firstName: '', lastName: '', lastMessageTimeMs: 0 }); } const rows = Array.from(byLogin.values()).sort((a, b) => { const timeDiff = Number(b.lastMessageTimeMs || 0) - Number(a.lastMessageTimeMs || 0); if (timeDiff) return timeDiff; return a.peerLogin.localeCompare(b.peerLogin, 'ru'); }); list.replaceChildren(); if (!rows.length) { const empty = document.createElement('div'); empty.className = 'meta-muted dm-forward-loading'; empty.textContent = 'Нет доступных чатов'; list.append(empty); return; } rows.forEach((item) => { const fullName = [item.firstName, item.lastName].filter(Boolean).join(' '); const row = document.createElement('button'); row.type = 'button'; row.className = 'ui-button dm-forward-chat-row'; const avatar = renderUserAvatar({ login: item.peerLogin, firstName: item.firstName, lastName: item.lastName, size: 'md', className: 'dm-forward-avatar', title: item.peerLogin, }); const copy = document.createElement('span'); copy.className = 'dm-forward-chat-copy'; const name = document.createElement('strong'); name.textContent = fullName || item.peerLogin; const login = document.createElement('span'); login.className = 'meta-muted'; login.textContent = `@${item.peerLogin}`; copy.append(name, login); const arrow = document.createElement('span'); arrow.className = 'dm-forward-chat-arrow'; arrow.textContent = '›'; row.append(avatar, copy, arrow); row.addEventListener('click', () => { const card = root.querySelector('.dm-forward-card'); if (!card) return; card.innerHTML = ''; const confirmTitle = document.createElement('h3'); confirmTitle.className = 'modal-title'; confirmTitle.textContent = 'Переслать сюда?'; const recipient = document.createElement('div'); recipient.className = 'dm-forward-confirm-peer'; recipient.append( renderUserAvatar({ login: item.peerLogin, firstName: item.firstName, lastName: item.lastName, size: 'lg' }), ); const recipientCopy = document.createElement('div'); const recipientName = document.createElement('strong'); recipientName.textContent = fullName || item.peerLogin; const recipientLogin = document.createElement('div'); recipientLogin.className = 'meta-muted'; recipientLogin.textContent = `@${item.peerLogin}`; recipientCopy.append(recipientName, recipientLogin); recipient.append(recipientCopy); const confirmPreview = document.createElement('div'); confirmPreview.className = 'dm-forward-preview dm-forward-preview--confirm'; confirmPreview.textContent = preview || (parsed?.fileAttachments?.length ? 'Вложение' : 'Сообщение'); const actions = document.createElement('div'); actions.className = 'form-actions-grid dm-forward-confirm-actions'; const back = document.createElement('button'); back.type = 'button'; back.className = 'secondary-btn'; back.textContent = 'Назад'; const confirm = document.createElement('button'); confirm.type = 'button'; confirm.className = 'primary-btn'; confirm.textContent = 'Переслать'; actions.append(back, confirm); card.append(confirmTitle, recipient, confirmPreview, actions); back.addEventListener('click', () => { close(); void openForwardMessageDialog({ message, sourceChatId, onConfirm }); }); confirm.addEventListener('click', async () => { if (confirm.disabled) return; confirm.disabled = true; back.disabled = true; confirm.textContent = 'Отправляем…'; try { await onConfirm?.(item.peerLogin); close(); showToast(`Сообщение переслано в чат @${item.peerLogin}`, { timeoutMs: 1500 }); } catch (error) { confirm.disabled = false; back.disabled = false; confirm.textContent = 'Переслать'; showToast(error?.message || 'Не удалось переслать сообщение', { kind: 'error', timeoutMs: 2400 }); } }); }); list.append(row); }); } catch (error) { list.replaceChildren(); const fail = document.createElement('div'); fail.className = 'meta-muted dm-forward-loading'; fail.textContent = `Не удалось загрузить чаты: ${error?.message || 'unknown'}`; list.append(fail); } } function openMessageActionsMenu({ anchorX = 0, anchorY = 0, messageText = '', infoText = '', canReply = false, canForward = true, showReadAloud = true, canEdit = false, canDelete = false, onReply, onForward, 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-forward')?.addEventListener('click', async () => { close(); if (typeof onForward === 'function') await onForward(); }); 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 formatVoiceDuration(durationMs = 0) { const total = Math.max(0, Math.round(Number(durationMs || 0) / 1000)); const minutes = Math.floor(total / 60); const seconds = total % 60; return `${minutes}:${String(seconds).padStart(2, '0')}`; } function createDmFileAttachmentCard(attachment) { const fileCard = document.createElement('div'); fileCard.className = 'dm-file-card'; const fileIcon = document.createElement('span'); fileIcon.className = 'dm-file-card__icon'; fileIcon.textContent = '📎'; const fileCopy = document.createElement('span'); fileCopy.className = 'dm-file-card__copy'; const fileName = document.createElement('strong'); fileName.className = 'dm-file-card__name'; fileName.textContent = attachment.name || 'file'; const fileMeta = document.createElement('span'); fileMeta.className = 'dm-file-card__meta'; const metaParts = [formatDmFileSize(attachment.size)]; if (Number(attachment?.version || 1) >= 2) metaParts.push('BitTorrent v2'); fileMeta.textContent = metaParts.join(' · '); fileCopy.append(fileName, fileMeta); const downloadBtn = document.createElement('button'); downloadBtn.type = 'button'; downloadBtn.className = 'ui-button dm-file-card__download'; downloadBtn.textContent = 'Скачать'; downloadBtn.addEventListener('click', async (event) => { event.stopPropagation(); if (downloadBtn.disabled) return; downloadBtn.disabled = true; const previousLabel = downloadBtn.textContent; downloadBtn.textContent = 'Скачивание…'; try { const result = await downloadAndDecryptDmFile(attachment, { onProgress: ({ processedBytes, totalBytes }) => { if (!totalBytes) return; const percent = Math.min(100, Math.max(0, Math.round((processedBytes / totalBytes) * 100))); downloadBtn.textContent = `${percent}%`; }, }); if (!result?.cancelled) { showToast( result?.streamed ? 'Файл расшифрован по частям и сохранён напрямую на диск' : 'Файл расшифрован и передан браузеру для сохранения', { timeoutMs: 1800 }, ); } } catch (error) { showToast(error?.message || 'Не удалось скачать файл', { kind: 'error', timeoutMs: 2800 }); } finally { downloadBtn.disabled = false; downloadBtn.textContent = previousLabel; } }); fileCard.append(fileIcon, fileCopy, downloadBtn); return fileCard; } function createDmVoiceAttachmentCard(attachment) { const card = document.createElement('div'); card.className = 'dm-voice-card'; const playBtn = document.createElement('button'); playBtn.type = 'button'; playBtn.className = 'ui-button dm-voice-card__play'; playBtn.setAttribute('aria-label', 'Воспроизвести голосовое сообщение'); playBtn.textContent = '▶'; const body = document.createElement('div'); body.className = 'dm-voice-card__body'; const track = document.createElement('button'); track.type = 'button'; track.className = 'ui-button dm-voice-card__track'; track.setAttribute('aria-label', 'Перемотать голосовое сообщение'); const fill = document.createElement('span'); fill.className = 'dm-voice-card__fill'; track.append(fill); const meta = document.createElement('div'); meta.className = 'dm-voice-card__meta'; const duration = document.createElement('span'); duration.textContent = formatVoiceDuration(attachment?.durationMs || 0); const label = document.createElement('span'); label.textContent = 'Голосовое'; meta.append(duration, label); body.append(track, meta); card.append(playBtn, body); let audio = null; let objectUrl = ''; let loading = false; const syncProgress = () => { if (!audio) return; if (!card.isConnected) { try { audio.pause(); } catch { /* ignore */ } if (objectUrl) { URL.revokeObjectURL(objectUrl); objectUrl = ''; } return; } const total = Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : Math.max(0, Number(attachment?.durationMs || 0) / 1000); const current = Math.max(0, Number(audio.currentTime || 0)); fill.style.width = `${total > 0 ? Math.min(100, (current / total) * 100) : 0}%`; duration.textContent = `${formatVoiceDuration(current * 1000)} / ${formatVoiceDuration(total * 1000)}`; playBtn.textContent = audio.paused ? '▶' : 'Ⅱ'; }; const ensureAudio = async () => { if (audio) return audio; if (loading) return null; loading = true; playBtn.disabled = true; playBtn.textContent = '…'; try { const blob = await decryptDmFileToBlob(attachment); objectUrl = URL.createObjectURL(blob); audio = new Audio(objectUrl); audio.preload = 'metadata'; audio.addEventListener('timeupdate', syncProgress); audio.addEventListener('loadedmetadata', syncProgress); audio.addEventListener('play', syncProgress); audio.addEventListener('pause', syncProgress); audio.addEventListener('ended', syncProgress); audio.addEventListener('error', () => { showToast('Не удалось воспроизвести голосовое сообщение', { kind: 'error', timeoutMs: 2200 }); }); return audio; } finally { loading = false; playBtn.disabled = false; if (!audio) playBtn.textContent = '▶'; } }; playBtn.addEventListener('click', async (event) => { event.stopPropagation(); try { const player = await ensureAudio(); if (!player) return; if (player.paused) await player.play(); else player.pause(); syncProgress(); } catch (error) { showToast(error?.message || 'Не удалось открыть голосовое сообщение', { kind: 'error', timeoutMs: 2600 }); } }); track.addEventListener('click', async (event) => { event.stopPropagation(); try { const player = await ensureAudio(); if (!player || !Number.isFinite(player.duration) || player.duration <= 0) return; const rect = track.getBoundingClientRect(); const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / Math.max(1, rect.width))); player.currentTime = player.duration * ratio; syncProgress(); } catch (error) { showToast(error?.message || 'Не удалось открыть голосовое сообщение', { kind: 'error', timeoutMs: 2600 }); } }); return card; } 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 dmAttachments = Array.isArray(parsedText.fileAttachments) ? parsedText.fileAttachments : (parsedText.fileAttachment ? [parsedText.fileAttachment] : []); const autoKind = parsedText.callSummary ? 'call-tech' : (dmAttachments.length ? 'file-tech' : ''); const bubbleKind = String(msg?.kind || autoKind || '').trim(); bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`; const emojiOnly = !parsedText.callSummary && !dmAttachments.length && !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); } if (dmAttachments.length) { const attachmentsWrap = document.createElement('div'); attachmentsWrap.className = 'dm-attachment-stack'; for (const attachment of dmAttachments) { attachmentsWrap.append( attachment?.kind === 'voice' ? createDmVoiceAttachmentCard(attachment) : createDmFileAttachmentCard(attachment), ); } bubble.append(attachmentsWrap); } 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 && !dmAttachments.length) { 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 fileToggle = form.querySelector('#chat-file-toggle'); const fileInput = form.querySelector('#chat-file-input'); const sendBtn = form.querySelector('.dm-send-btn'); const voiceStatus = form.querySelector('#chat-voice-recording-status'); const voiceTime = form.querySelector('#chat-voice-recording-time'); const voiceHint = form.querySelector('#chat-voice-recording-hint'); const fileTransferEnabled = isDmFileTransferEnabled(); 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; let emojiLongPressTimer = null; let emojiLongPressTriggered = false; let fileActionVisible = false; let voiceRecorder = null; let voiceStream = null; let voiceChunks = []; let voiceStartedAt = 0; let voiceTimer = null; let voiceLocked = false; let voicePointerHeld = false; let voiceReleaseRequested = false; let voiceStartY = 0; let voicePointerId = null; let voiceBusy = false; let suppressVoiceClick = false; 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'); }; const clearEmojiLongPress = () => { if (emojiLongPressTimer != null) { window.clearTimeout(emojiLongPressTimer); emojiLongPressTimer = null; } }; const setFileActionVisible = (visible) => { fileActionVisible = Boolean(visible && fileTransferEnabled); if (fileToggle) fileToggle.hidden = !fileActionVisible; syncComposerLayout(); }; const sendIconMarkup = (mode) => { if (mode === 'send') { return ''; } if (mode === 'stop') { return ''; } if (mode === 'busy') { return ''; } return ''; }; const currentVoiceDurationMs = () => voiceStartedAt > 0 ? Math.max(0, Date.now() - voiceStartedAt) : 0; const syncVoiceTimer = () => { if (voiceTime) voiceTime.textContent = formatVoiceDuration(currentVoiceDurationMs()); }; const clearVoiceTimer = () => { if (voiceTimer != null) { window.clearInterval(voiceTimer); voiceTimer = null; } }; const stopVoiceStreamTracks = () => { try { voiceStream?.getTracks?.().forEach((track) => track.stop()); } catch { // ignore microphone cleanup failures } voiceStream = null; }; const updateSendButtonMode = () => { if (!sendBtn) return; const recording = Boolean(voiceRecorder && voiceRecorder.state !== 'inactive'); let mode = 'voice'; if (voiceBusy) mode = 'busy'; else if (recording && voiceLocked) mode = 'stop'; else if (recording) mode = 'voice'; else if (activeEdit || String(input?.value || '').trim()) mode = 'send'; sendBtn.dataset.mode = mode; sendBtn.innerHTML = sendIconMarkup(mode); sendBtn.classList.toggle('is-recording', recording); sendBtn.classList.toggle('is-locked', recording && voiceLocked); sendBtn.disabled = voiceBusy || (mode === 'voice' && !recording && !fileTransferEnabled); if (mode === 'send') { sendBtn.title = 'Отправить'; sendBtn.setAttribute('aria-label', 'Отправить'); } else if (mode === 'stop') { sendBtn.title = 'Остановить и отправить голосовое'; sendBtn.setAttribute('aria-label', 'Остановить и отправить голосовое'); } else if (mode === 'busy') { sendBtn.title = 'Шифрование и отправка голосового'; sendBtn.setAttribute('aria-label', 'Шифрование и отправка голосового'); } else { sendBtn.title = 'Удерживайте для записи голосового'; sendBtn.setAttribute('aria-label', 'Записать голосовое сообщение'); } form.classList.toggle('dm-chat-input--recording', recording); if (voiceStatus) voiceStatus.hidden = !recording; if (input) input.hidden = recording; if (emojiToggle) emojiToggle.hidden = recording; if (fileToggle && recording) fileToggle.hidden = true; if (!recording && voiceHint) voiceHint.textContent = '↑ проведите вверх, чтобы закрепить'; syncVoiceTimer(); }; const selectVoiceMimeType = () => { const candidates = [ 'audio/webm;codecs=opus', 'audio/ogg;codecs=opus', 'audio/mp4', 'audio/webm', ]; return candidates.find((mime) => globalThis.MediaRecorder?.isTypeSupported?.(mime)) || ''; }; const voiceExtension = (mime = '') => { const value = String(mime || '').toLowerCase(); if (value.includes('ogg')) return 'ogg'; if (value.includes('mp4') || value.includes('m4a')) return 'm4a'; return 'webm'; }; const stopVoiceRecording = ({ send = true } = {}) => { voicePointerHeld = false; voiceReleaseRequested = true; if (!voiceRecorder || voiceRecorder.state === 'inactive') return; voiceRecorder.__shineShouldSend = Boolean(send); try { voiceRecorder.stop(); } catch { stopVoiceStreamTracks(); } }; const startVoiceRecording = async ({ pointerY = 0, pointerId = null } = {}) => { if (voiceBusy || (voiceRecorder && voiceRecorder.state !== 'inactive')) return; if (!fileTransferEnabled) { showToast('Передача файлов и голосовых отключена в дополнительных настройках.', { kind: 'error', timeoutMs: 1900 }); return; } if (activeEdit) { showToast('Сначала завершите редактирование сообщения.', { kind: 'error', timeoutMs: 1600 }); return; } if (!globalThis.MediaRecorder || !navigator.mediaDevices?.getUserMedia) { showToast('Этот браузер не поддерживает запись голосовых сообщений.', { kind: 'error', timeoutMs: 2400 }); return; } voicePointerHeld = true; voiceReleaseRequested = false; voiceLocked = false; voiceStartY = Number(pointerY || 0); voicePointerId = pointerId; suppressVoiceClick = false; try { closeEmojiPicker(); setFileActionVisible(false); voiceStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }, video: false, }); const mimeType = selectVoiceMimeType(); const recorder = mimeType ? new MediaRecorder(voiceStream, { mimeType }) : new MediaRecorder(voiceStream); voiceRecorder = recorder; voiceChunks = []; recorder.__shineShouldSend = true; recorder.addEventListener('dataavailable', (event) => { if (event.data?.size > 0) voiceChunks.push(event.data); }); recorder.addEventListener('error', (event) => { showToast(event?.error?.message || 'Ошибка записи микрофона', { kind: 'error', timeoutMs: 2200 }); }); recorder.addEventListener('stop', async () => { const shouldSend = recorder.__shineShouldSend !== false; const durationMs = currentVoiceDurationMs(); const chunks = voiceChunks.slice(); voiceChunks = []; voiceRecorder = null; voiceLocked = false; voiceStartedAt = 0; clearVoiceTimer(); stopVoiceStreamTracks(); voiceBusy = shouldSend && chunks.some((chunk) => chunk.size > 0); updateSendButtonMode(); if (!voiceBusy) return; try { const mime = String(recorder.mimeType || mimeType || chunks[0]?.type || 'audio/webm'); const blob = new Blob(chunks, { type: mime }); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const file = new File([blob], `voice-${timestamp}.${voiceExtension(mime)}`, { type: mime, lastModified: Date.now() }); const attachment = await encryptAndUploadDmFile({ file, login: state.session.login, sessionId: state.session.sessionId, wsUrl: authService.serverUrl, kind: 'voice', durationMs, onProgress: ({ processedBytes, totalBytes }) => { if (!totalBytes) return; const percent = Math.round((processedBytes / totalBytes) * 100); if (voiceHint) voiceHint.textContent = `Шифрование и загрузка ${Math.min(100, percent)}%`; }, }); await sendFileAttachmentMessage([attachment]); showToast('Голосовое сообщение отправлено.', { timeoutMs: 1200 }); } catch (error) { showToast(error?.message || 'Не удалось отправить голосовое сообщение', { kind: 'error', timeoutMs: 2800 }); } finally { voiceBusy = false; updateSendButtonMode(); } }, { once: true }); voiceStartedAt = Date.now(); recorder.start(1000); voiceTimer = window.setInterval(syncVoiceTimer, 250); updateSendButtonMode(); if (voiceReleaseRequested && !voiceLocked) stopVoiceRecording({ send: true }); } catch (error) { voicePointerHeld = false; voiceReleaseRequested = false; voiceLocked = false; voiceRecorder = null; voiceStartedAt = 0; clearVoiceTimer(); stopVoiceStreamTracks(); updateSendButtonMode(); const denied = error?.name === 'NotAllowedError' || error?.name === 'SecurityError'; showToast( denied ? 'Нужно разрешить SHiNE доступ к микрофону.' : (error?.message || 'Не удалось начать запись'), { kind: 'error', timeoutMs: 2600 }, ); } }; // На сенсорных устройствах фокус в 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(); updateSendButtonMode(); }; 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(); } updateSendButtonMode(); }; const cancelReplyMode = ({ restoreDraft = true } = {}) => { if (!activeReply) return; activeReply = null; syncEditBanner(); if (restoreDraft && input) { autoResizeComposer(input); syncComposerLayout(); focusInputToEnd(); } updateSendButtonMode(); }; 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(); } updateSendButtonMode(); }; 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}`; // A new outgoing message immediately ends the visual "Новые сообщения" section. // Do this before awaiting the server: the divider is a UI-session marker, not a // delivery/read receipt indicator. Editing an existing message does not clear it. if (!editing) { hideUnreadSeparator({ rerender: false }); } 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', }); 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(); updateSendButtonMode(); 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 sendFileAttachmentMessage = async (attachmentsInput) => { const attachments = (Array.isArray(attachmentsInput) ? attachmentsInput : [attachmentsInput]).filter(Boolean); if (!attachments.length) return; if (attachments.length > DM_MAX_ATTACHMENTS_PER_MESSAGE) { throw new Error(`За одно сообщение можно отправить максимум ${DM_MAX_ATTACHMENTS_PER_MESSAGE} файлов`); } const replying = activeEdit ? null : activeReply; const fileBlocks = attachments.map((attachment) => buildDmFileTechBlock(attachment)).join(''); const replyBlock = replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''; const onlyVoice = attachments.length === 1 && attachments[0]?.kind === 'voice'; const fallbackText = onlyVoice ? '🎙 Голосовое сообщение' : (attachments.length === 1 ? `📎 ${String(attachments[0]?.name || 'file')}` : `📎 Файлы: ${attachments.length}`); const finalText = `${replyBlock}${fileBlocks}${fallbackText}`; hideUnreadSeparator({ rerender: false }); const tempId = addOutgoingPendingMessage(chatId, finalText); renderChatLog({ scrollMode: 'latest', markAsRead: false }); scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); try { const 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', }); if (replying) cancelReplyMode({ restoreDraft: false }); renderChatLog({ scrollMode: 'latest', markAsRead: false }); if (localRevisionApplied) notifyUnreadStateUpdated(); scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); window.requestAnimationFrame(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true })); addAppLogEntry({ level: 'info', source: onlyVoice ? 'outgoing-dm-voice' : 'outgoing-dm-file', message: onlyVoice ? `Голосовое отправлено для ${chatId}` : `Вложения отправлены для ${chatId}`, details: { toLogin: chatId, attachmentCount: attachments.length, fileIds: attachments.map((attachment) => attachment?.id || ''), totalFileSize: attachments.reduce((sum, attachment) => sum + Number(attachment?.size || 0), 0), messageId: result?.outgoingKey || '', replyToBaseKey: replying?.baseKey || '', }, }); } catch (error) { addChatMessage(chatId, `Ошибка отправки вложения: ${error?.message || 'unknown'}`); addAppLogEntry({ level: 'warn', source: onlyVoice ? 'outgoing-dm-voice' : 'outgoing-dm-file', message: 'Зашифрованные данные загружены, но DM с ключами не отправлен', details: { toLogin: chatId, fileIds: attachments.map((attachment) => attachment?.id || ''), error: error?.message || 'unknown', }, }); renderChatLog({ scrollMode: 'latest', markAsRead: false }); throw error; } }; const handleSelectedFiles = async (fileList) => { if (!fileTransferEnabled) { showToast('Передача файлов отключена в дополнительных настройках.', { kind: 'error', timeoutMs: 1800 }); return; } if (activeEdit) { showToast('Сначала завершите редактирование сообщения.', { kind: 'error', timeoutMs: 1600 }); return; } const allFiles = Array.from(fileList || []).filter(Boolean); if (!allFiles.length) return; if (allFiles.length > DM_MAX_ATTACHMENTS_PER_MESSAGE) { showToast(`Выберите не больше ${DM_MAX_ATTACHMENTS_PER_MESSAGE} файлов за одно сообщение.`, { kind: 'error', timeoutMs: 2200 }); return; } const originalLabel = fileToggle?.textContent || '📎'; if (fileToggle) { fileToggle.disabled = true; fileToggle.textContent = '…'; fileToggle.setAttribute('aria-label', 'Шифрование и загрузка файлов'); } const attachments = []; try { for (let index = 0; index < allFiles.length; index += 1) { const file = allFiles[index]; showToast(`Файл ${index + 1}/${allFiles.length}: «${String(file.name || 'file')}»`, { timeoutMs: 1200 }); const attachment = await encryptAndUploadDmFile({ file, login: state.session.login, sessionId: state.session.sessionId, wsUrl: authService.serverUrl, onProgress: ({ processedBytes, totalBytes }) => { if (!fileToggle || !totalBytes) return; const percent = Math.min(100, Math.max(0, Math.round((processedBytes / totalBytes) * 100))); fileToggle.title = `${index + 1}/${allFiles.length} · ${percent}%`; }, }); attachments.push(attachment); } await sendFileAttachmentMessage(attachments); setFileActionVisible(false); showToast(attachments.length === 1 ? 'Файл отправлен.' : `Отправлено файлов: ${attachments.length}.`, { timeoutMs: 1300 }); } catch (error) { showToast(error?.message || 'Не удалось отправить файлы', { kind: 'error', timeoutMs: 2800 }); } finally { if (fileToggle) { fileToggle.disabled = false; fileToggle.textContent = originalLabel; fileToggle.title = 'Прикрепить файлы'; fileToggle.setAttribute('aria-label', 'Прикрепить файлы'); } if (fileInput) fileInput.value = ''; } }; const forwardMessageToChat = async (msg, targetLogin) => { const targetChatId = normalizeDmChatId(targetLogin); if (!targetChatId) throw new Error('Не выбран чат для пересылки'); const finalText = buildForwardedDmText(msg); if (!String(finalText || '').trim()) throw new Error('В этом сообщении нечего пересылать'); const tempId = addOutgoingPendingMessage(targetChatId, finalText); if (targetChatId === chatId) { hideUnreadSeparator({ rerender: false }); renderChatLog({ scrollMode: 'latest', markAsRead: false }); scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); } try { const result = await authService.sendDirectMessage({ login: state.session.login, toLogin: targetChatId, text: finalText, storagePwd: state.session.storagePwdInMemory, }); markOutgoingSent(tempId, { messageKey: result?.outgoingKey || '', baseKey: result?.baseKey || result?.localBaseKey || '', deliveryState: result?.deliveryState || 'accepted', }); if (targetChatId === chatId) { renderChatLog({ scrollMode: 'latest', markAsRead: false }); scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }); } addAppLogEntry({ level: 'info', source: 'outgoing-dm-forward', message: `Сообщение переслано для ${targetChatId}`, details: { toLogin: targetChatId, sourceChatId: chatId, messageId: result?.outgoingKey || '', }, }); } catch (error) { // Оставляем pending-сообщение в локальной истории так же, как обычная отправка, // а пользователю возвращаем ошибку для повторной попытки. if (targetChatId === chatId) renderChatLog({ scrollMode: 'latest', markAsRead: false }); throw error; } }; 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, canForward: true, showReadAloud: isTextToSpeechReady, canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary && !(parsed?.fileAttachments?.length || parsed?.fileAttachment), canDelete: ( (msg?.from === 'out' && Number(msg?.messageType || 0) === 2) || (msg?.from === 'in' && Number(msg?.messageType || 0) === 1) ), onReply: async () => { startReplyMode(msg); }, onForward: async () => { await openForwardMessageDialog({ message: msg, sourceChatId: chatId, onConfirm: async (targetLogin) => forwardMessageToChat(msg, targetLogin), }); }, 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(); updateSendButtonMode(); input?.addEventListener('input', () => { rememberEmojiSelection(); autoResizeComposer(input); syncComposerLayout(); updateSendButtonMode(); }); 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(); emojiLongPressTriggered = false; clearEmojiLongPress(); if (fileTransferEnabled) { emojiLongPressTimer = window.setTimeout(() => { emojiLongPressTimer = null; emojiLongPressTriggered = true; closeEmojiPicker(); setFileActionVisible(true); }, 520); } try { emojiToggle.setPointerCapture?.(event.pointerId); } catch { // Pointer capture is optional; long press still works without it. } event.preventDefault(); }); emojiToggle?.addEventListener('pointerup', clearEmojiLongPress); emojiToggle?.addEventListener('pointercancel', clearEmojiLongPress); emojiToggle?.addEventListener('pointerleave', (event) => { if (event.buttons) clearEmojiLongPress(); }); emojiToggle?.addEventListener('contextmenu', (event) => { if (fileTransferEnabled) event.preventDefault(); }); emojiToggle?.addEventListener('click', (event) => { if (emojiLongPressTriggered) { emojiLongPressTriggered = false; event.preventDefault(); return; } setFileActionVisible(false); emojiPickerOpen = !emojiPickerOpen; emojiSlot.hidden = !emojiPickerOpen; emojiToggle.setAttribute('aria-expanded', String(emojiPickerOpen)); // Либо клавиатура, либо пикер. На тач-устройствах при // открытии пикера прячем клавиатуру, чтобы поле ввода оставалось видно. if (emojiPickerOpen && !isFinePointer) input?.blur(); }); fileToggle?.addEventListener('click', () => { if (!fileTransferEnabled || fileToggle.disabled) return; fileInput?.click(); }); fileInput?.addEventListener('change', () => { const files = Array.from(fileInput.files || []); if (files.length) void handleSelectedFiles(files); }); screen.addEventListener('pointerdown', (event) => { if (form.contains(event.target)) return; if (emojiPickerOpen) closeEmojiPicker(); if (fileActionVisible) setFileActionVisible(false); }); sendBtn?.addEventListener('pointerdown', (event) => { const mode = String(sendBtn.dataset.mode || 'voice'); if (mode === 'busy') { event.preventDefault(); return; } if (mode === 'send') { try { input?.focus({ preventScroll: true }); } catch { input?.focus(); } return; } if (mode === 'stop') { event.preventDefault(); suppressVoiceClick = true; stopVoiceRecording({ send: true }); return; } event.preventDefault(); voicePointerHeld = true; voiceReleaseRequested = false; voiceStartY = Number(event.clientY || 0); voicePointerId = event.pointerId; try { sendBtn.setPointerCapture?.(event.pointerId); } catch { /* optional */ } void startVoiceRecording({ pointerY: voiceStartY, pointerId: event.pointerId }); }); sendBtn?.addEventListener('pointermove', (event) => { if (!voicePointerHeld || voiceLocked || voicePointerId !== event.pointerId) return; if (!voiceRecorder || voiceRecorder.state === 'inactive') return; if (Number(event.clientY || 0) <= voiceStartY - 64) { voiceLocked = true; voiceReleaseRequested = false; if (voiceHint) voiceHint.textContent = 'Запись закреплена · нажмите ■, чтобы отправить'; updateSendButtonMode(); try { navigator.vibrate?.(18); } catch { /* ignore */ } } }); const finishVoicePointerGesture = (event) => { if (!voicePointerHeld && voicePointerId == null) return; if (voicePointerId != null && event.pointerId !== voicePointerId) return; voicePointerHeld = false; voiceReleaseRequested = true; try { sendBtn?.releasePointerCapture?.(event.pointerId); } catch { /* optional */ } voicePointerId = null; if (!voiceLocked) { suppressVoiceClick = true; stopVoiceRecording({ send: true }); } }; sendBtn?.addEventListener('pointerup', finishVoicePointerGesture); sendBtn?.addEventListener('pointercancel', finishVoicePointerGesture); sendBtn?.addEventListener('contextmenu', (event) => { if (String(sendBtn.dataset.mode || '') !== 'send') event.preventDefault(); }); sendBtn?.addEventListener('click', (event) => { event.preventDefault(); if (suppressVoiceClick) { suppressVoiceClick = false; return; } const mode = String(sendBtn.dataset.mode || 'voice'); if (mode === 'send') { form.requestSubmit(); } else if (mode === 'stop') { stopVoiceRecording({ send: true }); } }); form.addEventListener('submit', async (event) => { event.preventDefault(); const text = String(input.value || '').trim(); if (!text) return; input.value = ''; autoResizeComposer(input); syncComposerLayout(); updateSendButtonMode(); closeEmojiPicker(); await sendTextMessage(text); focusInputToEnd(); }); const handleIncomingChatRefresh = async (event) => { const updatedChatId = normalizeDmChatId(event?.detail?.chatId); if (updatedChatId !== chatId) return; // Если диалог уже открыт, новое входящее сразу показывается в текущем потоке и не создаёт // новый визуальный разделитель «Новые сообщения». Такой разделитель относится только к // непрочитанным, которые существовали ДО открытия экрана чата. 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(); clearEmojiLongPress(); try { stopVoiceRecording({ send: false }); } catch { /* ignore */ } clearVoiceTimer(); stopVoiceStreamTracks(); 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' }, }); } } }