Files

2660 lines
104 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '<path d="M9 7 4 12l5 5"/><path d="M5 12h8a6 6 0 0 1 6 6"/>',
forward: '<path d="m15 7 5 5-5 5"/><path d="M19 12h-8a6 6 0 0 0-6 6"/>',
copy: '<rect x="8" y="8" width="10" height="10" rx="2"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"/>',
read: '<path d="M5 9v6h4l5 4V5L9 9H5Z"/><path d="M17 9a4 4 0 0 1 0 6"/>',
edit: '<path d="m4 20 4.4-1 9.9-9.9a2.1 2.1 0 0 0-3-3L5.4 16 4 20Z"/><path d="m13.8 7.2 3 3"/>',
delete: '<path d="M4 7h16"/><path d="M9 7V4h6v3"/><path d="m7 7 1 13h8l1-13"/>',
call: '<path d="M7.6 10.8a15 15 0 0 0 5.6 5.6l1.9-1.9a1 1 0 0 1 1-.2c1.1.4 2.3.6 3.5.6a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1C10.6 21 3 13.4 3 4a1 1 0 0 1 1-1h3.4a1 1 0 0 1 1 1c0 1.2.2 2.4.6 3.5a1 1 0 0 1-.3 1l-1.1 1.3Z"/>',
video: '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3"/>',
clear: '<path d="M4 7h16"/><path d="M9 7V4h6v3"/><path d="m7 7 1 13h8l1-13"/>',
};
return `<svg class="dm-menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ''}</svg>`;
}
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 = `
<div class="modal" id="chat-delete-message-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">Удалить сообщение?</h3>
<p class="meta-muted">Оно исчезнет и у тебя, и у собеседника.</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="chat-delete-message-no">Нет</button>
<button class="primary-btn" type="button" id="chat-delete-message-yes">Да</button>
</div>
</div>
</div>
`;
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 = `
<div class="modal" id="chat-confirm-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">${title}</h3>
<p class="meta-muted">${text}</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="chat-confirm-no">Нет</button>
<button class="${confirmClass}" type="button" id="chat-confirm-yes">${confirmLabel}</button>
</div>
</div>
</div>
`;
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 = `
<div class="modal" id="chat-delete-chat-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">Удалить чат?</h3>
${isProtectedRelation ? `
<p class="meta-muted">Можно удалить содержимое переписки, но чат с ${isCloseFriend ? 'близким другом' : 'другом'} останется в списке.</p>
<p class="meta-muted">Удалить ${safeName} из ${relationName} и удалить чат?</p>
` : `
<p class="meta-muted">Удалить пользователя ${safeName} из контактов?</p>
<label class="dm-confirm-check">
<input type="checkbox" id="chat-delete-chat-history" checked />
<span>Также удалить всю историю переписки</span>
</label>
`}
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="chat-delete-chat-no">Нет</button>
<button class="destructive-btn" type="button" id="chat-delete-chat-yes">Да</button>
</div>
</div>
</div>
`;
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 = `
<div class="modal dm-forward-modal" id="chat-forward-modal">
<div class="modal-card stack dm-forward-card">
<div class="dm-forward-head">
<div>
<h3 class="modal-title">Переслать сообщение</h3>
<p class="meta-muted">Выберите чат</p>
</div>
<button class="ui-button dm-forward-close" type="button" id="chat-forward-close" aria-label="Закрыть">×</button>
</div>
<div class="dm-forward-preview" id="chat-forward-preview"></div>
<div class="dm-forward-list" id="chat-forward-list">
<div class="meta-muted dm-forward-loading">Загружаем чаты…</div>
</div>
</div>
</div>
`;
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 = `
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
${canReply ? `<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-reply">${menuIconSvg('reply')}<span>Ответить</span></button>` : ''}
${canForward ? `<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-forward">${menuIconSvg('forward')}<span>Переслать</span></button>` : ''}
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">${menuIconSvg('copy')}<span>Скопировать как текст</span></button>
${showReadAloud ? `<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">${menuIconSvg('read')}<span>Прочесть</span></button>` : ''}
${canEdit ? `<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">${menuIconSvg('edit')}<span>Изменить</span></button>` : ''}
${canDelete ? `<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="msg-action-delete">${menuIconSvg('delete')}<span>Удалить</span></button>` : ''}
${String(infoText || '').trim() ? `<div class="meta-muted">${String(infoText || '').trim()}</div>` : ''}
</div>
</div>
`;
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 = `
<div class="modal" id="chat-tts-missing-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">Озвучка не настроена</h3>
<p class="meta-muted">Функция голосовых инструментов сейчас отключена.</p>
<div class="form-actions-grid">
<button class="primary-btn" type="button" id="chat-tts-ok">OK</button>
</div>
</div>
</div>
`;
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 = `
<div class="modal" id="contact-confirm-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">Добавить собеседника</h3>
<p class="meta-muted">Добавить пользователя @${targetLogin} в контакты?</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="contact-confirm-no">Нет</button>
<button class="primary-btn" type="button" id="contact-confirm-yes">Да</button>
</div>
</div>
</div>
`;
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 = `
<div class="dm-history-loader__pill" aria-live="polite" aria-busy="true">
<span class="dm-history-loader__spinner" aria-hidden="true"></span>
<span class="dm-history-loader__label">Загрузка истории…</span>
</div>
`;
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: '<span aria-hidden="true"></span>',
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 = `
<div class="dm-edit-banner" id="chat-edit-banner" hidden>
<div class="dm-edit-banner__text" id="chat-edit-banner-text"></div>
<button class="ghost-btn dm-edit-banner__close" type="button" id="chat-edit-cancel" title="Отменить редактирование">✕</button>
</div>
<div class="emoji-picker-slot" id="chat-emoji-picker-slot" hidden></div>
<div class="dm-voice-recording-status" id="chat-voice-recording-status" hidden>
<span class="dm-voice-recording-dot" aria-hidden="true"></span>
<span class="dm-voice-recording-time" id="chat-voice-recording-time">0:00</span>
<span class="dm-voice-recording-hint" id="chat-voice-recording-hint">↑ проведите вверх, чтобы закрепить</span>
</div>
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000" enterkeyhint="enter"></textarea>
<input id="chat-file-input" type="file" multiple hidden />
<div class="dm-actions-col">
<button class="ghost-btn dm-file-btn" type="button" id="chat-file-toggle" aria-label="Прикрепить файлы" title="Прикрепить файлы" hidden>📎</button>
<button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи"><span class="dm-emoji-btn__glyph">☺</span></button>
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="button" title="Записать голосовое сообщение" data-mode="voice" aria-label="Записать голосовое сообщение"></button>
</div>
`;
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 '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12 20 4l-5.5 16-3.1-6.4L4 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/><path d="m11.4 13.6 3.4-3.4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
}
if (mode === 'stop') {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg>';
}
if (mode === 'busy') {
return '<span class="dm-send-busy-dot">…</span>';
}
return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3" width="6" height="11" rx="3" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M6.5 11.5a5.5 5.5 0 0 0 11 0M12 17v4M9 21h6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
};
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' },
});
}
}
}