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 = `
${title}
${text}
`;
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 = `
Удалить чат?
${isProtectedRelation ? `
Можно удалить содержимое переписки, но чат с ${isCloseFriend ? 'близким другом' : 'другом'} останется в списке.
Удалить ${safeName} из ${relationName} и удалить чат?
` : `
Удалить пользователя ${safeName} из контактов?
`}
`;
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 = `
0:00
↑ проведите вверх, чтобы закрепить
`;
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' },
});
}
}
}