import { renderHeader } from '../components/header.js';
import { directMessages } from '../mock-data.js';
import {
addAppLogEntry,
addChatMessage,
addSignedMessageToChat,
addSystemChatMessage,
addOutgoingPendingMessage,
deleteConversationMessagesBefore,
getChatMessages,
markChatRead,
markOutgoingSent,
markReadReceiptSentByBaseKey,
normalizeDmChatId,
authService,
setContacts,
state,
} from '../state.js';
import { startOutgoingCall } from '../services/call-service.js';
import {
createEmojiPicker,
createTelegramAnimatedEmoji,
appendTelegramAnimatedText,
isTelegramAnimatedEmoji,
stopAllTelegramEmojiAnimations,
} from '../components/emoji-picker.js?v=202607152130';
import { openSpeechInputModal } from '../components/speech-input-modal.js';
import { isSpeechToTextConfigured, isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
function truncatePreviewText(value, maxLen = 72) {
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
if (!normalized) return '';
if (normalized.length <= maxLen) return normalized;
return `${normalized.slice(0, Math.max(0, maxLen - 1)).trimEnd()}…`;
}
function findMessageByBaseKey(messages, baseKey) {
const normalized = String(baseKey || '').trim();
if (!normalized) return null;
return messages.find((row) => String(row?.baseKey || '').trim() === normalized) || null;
}
function openDeleteMessageConfirmModal({ onConfirm }) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
Удалить сообщение?
Оно исчезнет и у тебя, и у собеседника.
`;
const close = () => {
root.innerHTML = '';
};
root.querySelector('#chat-delete-message-no')?.addEventListener('click', close);
root.querySelector('#chat-delete-message-yes')?.addEventListener('click', async () => {
close();
if (typeof onConfirm === 'function') await onConfirm();
});
}
function openChatConfirmModal({
title = 'Подтвердить действие',
text = '',
confirmLabel = 'Да',
confirmKind = 'primary',
onConfirm,
}) {
const root = document.getElementById('modal-root');
if (!root) return;
const confirmClass = confirmKind === 'danger' ? 'destructive-btn' : 'primary-btn';
root.innerHTML = `
${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 = '', onConfirm }) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
`;
const close = () => {
root.innerHTML = '';
};
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => {
const deleteHistory = Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
close();
if (typeof onConfirm === 'function') {
await onConfirm({ deleteHistory });
}
});
}
function openMessageActionsMenu({
anchorX = 0,
anchorY = 0,
messageText = '',
canReply = false,
showReadAloud = true,
canEdit = false,
canDelete = false,
onReply,
onReadAloud,
onEdit,
onDelete,
}) {
const root = document.getElementById('modal-root');
if (!root) return;
const menuId = `chat-message-actions-menu-${Date.now()}`;
root.innerHTML = `
`;
const menu = root.querySelector(`#${menuId}`);
if (!menu) return;
const close = () => {
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
window.removeEventListener('resize', close);
window.removeEventListener('scroll', close, true);
root.innerHTML = '';
};
const onDocumentPointerDown = (event) => {
if (menu.contains(event.target)) return;
close();
};
document.addEventListener('pointerdown', onDocumentPointerDown, true);
window.addEventListener('resize', close);
window.addEventListener('scroll', close, true);
window.requestAnimationFrame(() => {
const menuRect = menu.getBoundingClientRect();
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
const left = Math.min(
Math.max(12, Number(anchorX || 0) - (menuRect.width / 2)),
Math.max(12, viewportWidth - menuRect.width - 12)
);
const top = Math.min(
Math.max(12, Number(anchorY || 0) + 10),
Math.max(12, viewportHeight - menuRect.height - 12)
);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
menu.style.transformOrigin = `${Math.round(Number(anchorX || left) - left)}px top`;
menu.classList.add('is-visible');
});
root.querySelector('#msg-action-copy')?.addEventListener('click', async () => {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(String(messageText || ''));
}
showToast('Сообщение скопированно', { timeoutMs: 1000 });
} catch {
showToast('Не удалось скопировать сообщение', { kind: 'error', timeoutMs: 1200 });
} finally {
close();
}
});
root.querySelector('#msg-action-reply')?.addEventListener('click', async () => {
close();
if (typeof onReply === 'function') await onReply();
});
root.querySelector('#msg-action-read')?.addEventListener('click', async () => {
close();
if (typeof onReadAloud === 'function') await onReadAloud();
});
root.querySelector('#msg-action-edit')?.addEventListener('click', async () => {
close();
if (typeof onEdit === 'function') await onEdit();
});
root.querySelector('#msg-action-delete')?.addEventListener('click', async () => {
close();
if (typeof onDelete === 'function') await onDelete();
});
}
function openChatActionsMenu({
anchorX = 0,
anchorY = 0,
onCall,
onClearHistory,
onDeleteChat,
}) {
const root = document.getElementById('modal-root');
if (!root) return;
const menuId = `chat-header-actions-menu-${Date.now()}`;
root.innerHTML = `
`;
const menu = root.querySelector(`#${menuId}`);
if (!menu) return;
const close = () => {
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
window.removeEventListener('resize', close);
window.removeEventListener('scroll', close, true);
root.innerHTML = '';
};
const onDocumentPointerDown = (event) => {
if (menu.contains(event.target)) return;
close();
};
document.addEventListener('pointerdown', onDocumentPointerDown, true);
window.addEventListener('resize', close);
window.addEventListener('scroll', close, true);
window.requestAnimationFrame(() => {
const menuRect = menu.getBoundingClientRect();
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
const left = Math.min(
Math.max(12, Number(anchorX || 0) - menuRect.width + 8),
Math.max(12, viewportWidth - menuRect.width - 12)
);
const top = Math.min(
Math.max(12, Number(anchorY || 0) + 10),
Math.max(12, viewportHeight - menuRect.height - 12)
);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
menu.style.transformOrigin = `${Math.round(Number(anchorX || left) - left)}px top`;
menu.classList.add('is-visible');
});
root.querySelector('#chat-menu-call')?.addEventListener('click', async () => {
close();
if (typeof onCall === 'function') await onCall();
});
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
close();
if (typeof onClearHistory === 'function') await onClearHistory();
});
root.querySelector('#chat-menu-delete-chat')?.addEventListener('click', async () => {
close();
if (typeof onDeleteChat === 'function') await onDeleteChat();
});
}
function showTtsMissingConfigDialog(navigate) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
Озвучка не настроена
Перейти в настройки инструментов?
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#chat-tts-no')?.addEventListener('click', close);
root.querySelector('#chat-tts-yes')?.addEventListener('click', () => {
close();
navigate('tools-settings-view');
});
}
function autoResizeComposer(textarea) {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(180, Math.max(42, textarea.scrollHeight))}px`;
}
function openConfirmContactModal(targetLogin = '') {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(false);
return new Promise((resolve) => {
root.innerHTML = `
`;
const close = (answer) => {
root.innerHTML = '';
resolve(!!answer);
};
root.querySelector('#contact-confirm-no')?.addEventListener('click', () => close(false));
root.querySelector('#contact-confirm-yes')?.addEventListener('click', () => close(true));
});
}
function parseBaseKey(baseKey) {
const raw = String(baseKey || '').trim();
const parts = raw.split('|');
if (parts.length < 4) return null;
const fromLogin = parts[0] || '';
const toLogin = parts[1] || '';
const timeMs = Number(parts[2] || 0);
const nonce = Number(parts[3] || 0);
if (!fromLogin || !toLogin || !Number.isFinite(timeMs) || !Number.isFinite(nonce)) return null;
return { fromLogin, toLogin, timeMs, nonce };
}
function resolveMessageTimeMs(msg) {
const base = parseBaseKey(msg?.baseKey);
if (base?.timeMs && Number.isFinite(base.timeMs) && base.timeMs > 0) return base.timeMs;
const messageKey = String(msg?.messageKey || '').trim();
if (messageKey) {
const parts = messageKey.split('|');
const timeMs = Number(parts[2] || 0);
if (parts.length >= 4 && Number.isFinite(timeMs) && timeMs > 0) return timeMs;
}
const tempId = String(msg?.tempId || '').trim();
if (tempId.startsWith('tmp-')) {
const ts = Number(tempId.split('-')[1] || 0);
if (Number.isFinite(ts) && ts > 0) return ts;
}
const fallback = Number(msg?.createdAtMs || 0);
if (Number.isFinite(fallback) && fallback > 0) return fallback;
return 0;
}
function formatMessageTime(valueMs) {
const timeMs = Number(valueMs || 0);
if (!Number.isFinite(timeMs) || timeMs <= 0) return '';
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(timeMs));
}
function formatCallDurationCompact(totalSeconds) {
const total = Math.max(0, Math.floor(Number(totalSeconds || 0)));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function buildCallStatusText(callSummary) {
if (!callSummary) return '';
if (callSummary.status === 'completed') {
return `Длительность: ${formatCallDurationCompact(callSummary.durationSec)}`;
}
const reason = String(callSummary.reason || '').trim().toLowerCase();
if (reason === 'offline') return 'Не дозвонился: абонент не в сети';
if (reason === 'no_answer') return 'Не дозвонился: нет ответа';
if (reason === 'connect_failed') return 'Не дозвонился: не удалось установить соединение';
if (reason === 'busy') return 'Не дозвонился: абонент занят';
if (reason === 'declined') return 'Не дозвонился: звонок отклонён';
return 'Не дозвонился';
}
function resolveDeliveryStatus(msg) {
if (msg?.from !== 'out') return '';
if (msg?.secondTick) return '✓✓';
if (msg?.firstTick) return '✓';
return '…';
}
function resolveMessageEditedTimeMs(msg) {
const revisionTimeMs = Number(msg?.revisionTimeMs || 0);
if (!Number.isFinite(revisionTimeMs) || revisionTimeMs <= 0) return 0;
return revisionTimeMs;
}
function scrollToLatestMessage(list) {
if (!list) return;
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
const lastBubble = list.lastElementChild;
const apply = () => {
if (lastBubble?.scrollIntoView) {
lastBubble.scrollIntoView({ block: 'end', inline: 'nearest' });
}
scrollContainer.scrollTop = scrollContainer.scrollHeight;
};
apply();
window.requestAnimationFrame(apply);
window.requestAnimationFrame(() => window.requestAnimationFrame(apply));
window.setTimeout(apply, 0);
window.setTimeout(apply, 60);
window.setTimeout(apply, 120);
window.setTimeout(apply, 260);
}
function renderMessageText(textNode, messageText) {
const text = String(messageText || '');
const emoji = text.trim();
if (text === emoji && isTelegramAnimatedEmoji(emoji)) {
textNode.classList.add('bubble-text--animated-emoji');
textNode.append(createTelegramAnimatedEmoji(emoji, { className: 'chat-telegram-emoji' }));
return;
}
if (appendTelegramAnimatedText(textNode, text, { className: 'chat-telegram-emoji chat-telegram-emoji--inline' })) {
textNode.classList.add('bubble-text--telegram-emoji-inline');
}
}
function scrollToLatestMessageSmart(list, { smoothIfNearBottom = false } = {}) {
if (!list) return;
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
const lastBubble = list.lastElementChild;
const maxScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight);
const distanceToBottom = Math.max(0, maxScrollTop - scrollContainer.scrollTop);
const useSmooth = smoothIfNearBottom && distanceToBottom <= 180;
const apply = (behavior = useSmooth ? 'smooth' : 'auto') => {
if (lastBubble?.scrollIntoView) {
lastBubble.scrollIntoView({ block: 'end', inline: 'nearest', behavior });
}
if (behavior === 'smooth' && typeof scrollContainer.scrollTo === 'function') {
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' });
} else {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
};
apply(useSmooth ? 'smooth' : 'auto');
window.requestAnimationFrame(() => apply('auto'));
window.setTimeout(() => apply('auto'), useSmooth ? 220 : 90);
}
function scrollToUnreadSeparator(list) {
if (!list) return false;
const separator = list.querySelector('.chat-unread-separator');
if (!separator) return false;
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
const apply = () => {
if (separator?.scrollIntoView) {
separator.scrollIntoView({ block: 'start', inline: 'nearest' });
}
const bottomSlack = 72;
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - bottomSlack);
};
apply();
window.requestAnimationFrame(apply);
window.requestAnimationFrame(() => window.requestAnimationFrame(apply));
window.setTimeout(apply, 60);
window.setTimeout(apply, 160);
return true;
}
function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode = 'latest' } = {}) {
list.innerHTML = '';
const messages = getChatMessages(chatId);
let unreadSeparatorInserted = false;
messages.forEach((msg) => {
if (!unreadSeparatorInserted && msg?.from === 'in' && msg?.unread) {
const sep = document.createElement('div');
sep.className = 'chat-unread-separator';
const label = document.createElement('span');
label.textContent = 'Новые сообщения';
sep.append(label);
list.append(sep);
unreadSeparatorInserted = true;
}
const bubble = document.createElement('div');
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
const autoKind = parsedText.callSummary ? 'call-tech' : '';
const bubbleKind = String(msg?.kind || autoKind || '').trim();
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
if (parsedText.callSummary) {
const callCard = document.createElement('div');
callCard.className = 'bubble-call-card';
const callHead = document.createElement('div');
callHead.className = 'bubble-call-head';
const callIcon = document.createElement('span');
callIcon.className = 'bubble-call-icon';
callIcon.textContent = '☎';
const callTitle = document.createElement('span');
callTitle.className = 'bubble-call-title';
callTitle.textContent = msg?.from === 'out' ? 'Исходящий звонок' : 'Входящий звонок';
callHead.append(callIcon, callTitle);
const callMetaLine = document.createElement('div');
callMetaLine.className = 'bubble-call-line bubble-call-line--muted';
const callStatus = buildCallStatusText(parsedText.callSummary);
callMetaLine.textContent = callStatus;
callCard.append(callHead, callMetaLine);
bubble.append(callCard);
}
const textNode = document.createElement('div');
textNode.className = 'bubble-text';
renderMessageText(textNode, parsedText.displayText || '');
if (parsedText.replyRef) {
const replyTarget = findMessageByBaseKey(messages, parsedText.replyRef.baseKey);
if (replyTarget) {
const replyParsed = parseDmTechBlocks(String(replyTarget?.text || ''));
const replyBox = document.createElement('button');
replyBox.type = 'button';
replyBox.className = 'bubble-reply-preview';
const replyAuthor = document.createElement('div');
replyAuthor.className = 'bubble-reply-preview-author';
replyAuthor.textContent = String(replyTarget?.from === 'out' ? state.session.login : parsedText.replyRef.fromLogin);
const replyText = document.createElement('div');
replyText.className = 'bubble-reply-preview-text';
replyText.textContent = truncatePreviewText(replyParsed.displayText || '', 96);
replyBox.append(replyAuthor, replyText);
replyBox.addEventListener('click', (event) => {
event.stopPropagation();
const targetNode = list.querySelector(`[data-base-key="${CSS.escape(String(replyTarget?.baseKey || ''))}"]`);
if (targetNode?.scrollIntoView) {
targetNode.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' });
}
});
bubble.append(replyBox);
}
}
if (!parsedText.callSummary) {
bubble.append(textNode);
}
const metaNode = document.createElement('div');
metaNode.className = 'bubble-meta';
const timeNode = document.createElement('span');
timeNode.className = 'bubble-time';
timeNode.textContent = formatMessageTime(resolveMessageTimeMs(msg));
metaNode.append(timeNode);
const status = resolveDeliveryStatus(msg);
if (status) {
const statusNode = document.createElement('span');
statusNode.className = 'bubble-status';
statusNode.textContent = status;
metaNode.append(statusNode);
}
bubble.append(metaNode);
const editedAtMs = resolveMessageEditedTimeMs(msg);
if (editedAtMs > 0) {
const editedNode = document.createElement('div');
editedNode.className = 'bubble-meta bubble-meta-edited';
editedNode.textContent = `изменено: ${formatMessageTime(editedAtMs)}`;
bubble.append(editedNode);
}
bubble.addEventListener('click', (event) => {
if (typeof onOpenActions === 'function') onOpenActions(msg, event);
});
bubble.dataset.baseKey = String(msg?.baseKey || '');
list.append(bubble);
});
if (scrollMode === 'unread' && !scrollToUnreadSeparator(list)) {
scrollToLatestMessage(list);
} else if (scrollMode === 'latest') {
scrollToLatestMessage(list);
}
if (markAsRead) {
markChatRead(chatId);
}
}
function preserveComposerSelection(input, callback) {
if (!input || typeof callback !== 'function') {
if (typeof callback === 'function') callback();
return;
}
const wasFocused = document.activeElement === input;
const start = Number(input.selectionStart ?? input.value.length);
const end = Number(input.selectionEnd ?? input.value.length);
callback();
if (!wasFocused) return;
try {
input.focus({ preventScroll: true });
} catch {
input.focus();
}
try {
input.setSelectionRange(start, end);
} catch {
// ignore
}
}
function setChatKeyboardOpen(isOpen) {
document.body.classList.toggle('chat-keyboard-open', !!isOpen);
}
export function render({ navigate, route }) {
const routeChatId = route.params.chatId || 'u1';
const chatId = normalizeDmChatId(routeChatId) || 'u1';
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
id: chatId,
name: String(routeChatId || chatId),
initials: (String(routeChatId || chatId)[0] || '?').toUpperCase(),
};
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-chat-screen';
const isSpeechToTextReady = isSpeechToTextConfigured(state.entrySettings);
const isTextToSpeechReady = isTextToSpeechConfigured(state.entrySettings);
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
const handleReadAloud = async (msg) => {
if (!isTextToSpeechConfigured(state.entrySettings)) {
showTtsMissingConfigDialog(navigate);
return;
}
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
await speakTextBySettings(String(parsedText.displayText || ''), state.entrySettings);
};
const handleStartCall = async () => {
try {
await startOutgoingCall(chatId);
renderLog(log, chatId, { onOpenActions: handleOpenActions });
} catch (e) {
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
from: 'out',
kind: 'call-tech',
});
renderLog(log, chatId, { onOpenActions: handleOpenActions });
}
};
const clearConversationHistory = async () => {
const result = await authService.deleteConversation({
login: state.session.login,
toLogin: chatId,
storagePwd: state.session.storagePwdInMemory,
deleteByRecipient: true,
});
const parsed = authService.parseSignedMessageBlob(String(result?.localBlobB64 || '').trim());
deleteConversationMessagesBefore(chatId, Number(parsed?.timeMs || 0));
addSignedMessageToChat({
chatId,
messageKey: String(parsed?.messageKey || ''),
baseKey: String(parsed?.baseKey || ''),
from: 'out',
text: CONVERSATION_CLEAR_NOTICE_TEXT,
messageType: Number(parsed?.messageType || 8),
unread: false,
rawBlobB64: String(result?.localBlobB64 || ''),
});
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead: false,
scrollMode: 'latest',
});
notifyUnreadStateUpdated();
};
const wrap = document.createElement('div');
wrap.className = 'chat-wrap dm-chat-wrap';
const log = document.createElement('div');
log.className = 'messages-log dm-messages-log';
screen.append(
renderHeader({
title: `Чат с ${contact.name}`,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [
{
label: '☎',
title: 'Позвонить',
ariaLabel: 'Позвонить',
className: 'chat-header-icon-btn chat-header-call-btn',
onClick: handleStartCall,
},
{
label: '⋯',
title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn',
onClick: (event) => {
openChatActionsMenu({
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
onCall: handleStartCall,
onClearHistory: async () => {
openChatConfirmModal({
title: 'Очистить историю?',
text: `Добавить техническое сообщение очистки истории переписки с ${contact.name}?`,
confirmLabel: 'Очистить',
confirmKind: 'danger',
onConfirm: async () => {
try {
await clearConversationHistory();
showToast('История чата очищена', { timeoutMs: 1200 });
} catch (error) {
showToast(`Не удалось очистить историю: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
});
},
onDeleteChat: async () => {
openDeleteChatConfirmModal({
contactName: contact.name,
onConfirm: async ({ deleteHistory }) => {
try {
if (deleteHistory) {
await clearConversationHistory();
}
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind: 'contact',
enabled: false,
storagePwd: state.session.storagePwdInMemory,
});
const contactsPayload = await authService.listContacts();
setContacts(contactsPayload?.contacts || []);
notifyUnreadStateUpdated();
showToast('Чат удалён из контактов', { timeoutMs: 1200 });
navigate('messages-list');
} catch (error) {
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
});
},
});
},
},
],
})
);
if (!isKnownContact) {
const card = document.createElement('div');
card.className = 'card';
const btn = document.createElement('button');
btn.className = 'secondary-btn';
btn.type = 'button';
btn.textContent = 'Добавить собеседника в контакты';
btn.addEventListener('click', async () => {
try {
const approved = await openConfirmContactModal(chatId);
if (!approved) return;
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind: 'contact',
enabled: true,
storagePwd: state.session.storagePwdInMemory,
});
const contactsPayload = await authService.listContacts();
setContacts(contactsPayload?.contacts || []);
addAppLogEntry({
level: 'info',
source: 'contacts',
message: `Пользователь ${chatId} добавлен в контакты`,
});
card.remove();
} catch (e) {
addAppLogEntry({
level: 'warn',
source: 'contacts',
message: 'Не удалось добавить пользователя в контакты',
details: { login: chatId, error: e?.message || 'unknown' },
});
}
});
card.append(btn);
screen.append(card);
}
const form = document.createElement('form');
form.className = 'chat-input dm-chat-input';
form.innerHTML = `
${isSpeechToTextReady ? '' : ''}
`;
const input = form.elements.message;
const emojiSlot = form.querySelector('#chat-emoji-picker-slot');
const emojiToggle = form.querySelector('#chat-emoji-toggle');
const editBanner = form.querySelector('#chat-edit-banner');
const editBannerText = form.querySelector('#chat-edit-banner-text');
const editCancelBtn = form.querySelector('#chat-edit-cancel');
let activeEdit = null;
let activeReply = null;
let inputFocused = false;
let emojiPickerOpen = false;
let emojiSelection = null;
const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
const syncKeyboardUi = () => {
const viewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
const viewportShrunk = baseViewportHeight - viewportHeight > 120;
setChatKeyboardOpen(inputFocused && viewportShrunk);
if (inputFocused) {
window.requestAnimationFrame(() => scrollToLatestMessage(log));
}
};
const rememberEmojiSelection = () => {
if (!input) return;
const valueLength = String(input.value || '').length;
const start = Math.max(0, Math.min(valueLength, Number(input.selectionStart ?? valueLength)));
const end = Math.max(start, Math.min(valueLength, Number(input.selectionEnd ?? start)));
emojiSelection = { start, end };
};
const closeEmojiPicker = () => {
if (!emojiPickerOpen) return;
emojiPickerOpen = false;
stopAllTelegramEmojiAnimations();
emojiSlot.hidden = true;
emojiToggle?.setAttribute('aria-expanded', 'false');
};
const insertEmoji = (emoji) => {
if (!input) return;
const value = String(input.value || '');
const start = emojiSelection?.start ?? Number(input.selectionStart ?? value.length);
const end = emojiSelection?.end ?? Number(input.selectionEnd ?? value.length);
input.value = `${value.slice(0, start)}${emoji}${value.slice(end)}`;
const nextPosition = start + emoji.length;
emojiSelection = { start: nextPosition, end: nextPosition };
input.focus();
try {
input.setSelectionRange(nextPosition, nextPosition);
} catch {
// Ignore browsers that do not expose textarea selection during focus changes.
}
autoResizeComposer(input);
};
if (emojiSlot) {
emojiSlot.append(createEmojiPicker({ onSelect: insertEmoji }));
}
const syncEditBanner = () => {
if (!editBanner || !editBannerText) return;
if (!activeEdit && !activeReply) {
editBanner.hidden = true;
editBannerText.textContent = '';
return;
}
editBanner.hidden = false;
if (activeEdit) {
editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`;
return;
}
editBannerText.textContent = `Ответить\n${truncatePreviewText(activeReply?.previewText || '', 110)}`;
};
const focusInputToEnd = () => {
if (!input) return;
input.focus();
try {
const nextPos = String(input.value || '').length;
input.setSelectionRange(nextPos, nextPos);
} catch {
// ignore
}
};
const cancelEditMode = ({ restoreDraft = true } = {}) => {
if (!activeEdit) return;
const draftToRestore = activeEdit.draftBeforeEdit;
activeEdit = null;
syncEditBanner();
if (restoreDraft && input) {
input.value = draftToRestore || '';
autoResizeComposer(input);
focusInputToEnd();
}
};
const cancelReplyMode = ({ restoreDraft = true } = {}) => {
if (!activeReply) return;
activeReply = null;
syncEditBanner();
if (restoreDraft && input) {
autoResizeComposer(input);
focusInputToEnd();
}
};
const startEditMode = (msg) => {
const base = parseBaseKey(msg?.baseKey);
if (!base || msg?.from !== 'out') return;
const parsed = parseDmTechBlocks(String(msg?.text || ''));
const currentDraft = String(input?.value || '');
activeReply = null;
activeEdit = {
messageKey: String(msg?.messageKey || ''),
originalText: String(parsed?.displayText || ''),
prefixText: String(parsed?.prefixText || ''),
draftBeforeEdit: activeEdit ? String(activeEdit.draftBeforeEdit || '') : currentDraft,
timeMs: base.timeMs,
nonce: base.nonce,
};
syncEditBanner();
if (input) {
input.value = String(parsed?.visibleText || '');
autoResizeComposer(input);
focusInputToEnd();
}
};
const startReplyMode = (msg) => {
const base = parseBaseKey(msg?.baseKey);
if (!base) return;
const parsed = parseDmTechBlocks(String(msg?.text || ''));
activeEdit = null;
activeReply = {
baseKey: String(msg?.baseKey || ''),
fromLogin: base.fromLogin,
toLogin: base.toLogin,
previewText: String(parsed?.displayText || parsed?.visibleText || ''),
};
syncEditBanner();
focusInputToEnd();
};
const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
if (!localOutgoingBlobB64) return;
try {
const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64);
let text = '';
if (Number(parsed?.messageType || 0) === 1 || Number(parsed?.messageType || 0) === 2) {
const decrypted = await authService.decryptSignedMessageContent({
parsed,
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
});
text = String(decrypted?.text || '');
}
addSignedMessageToChat({
chatId,
messageKey: fallbackMessageKey || parsed?.messageKey || '',
baseKey: fallbackBaseKey || parsed?.baseKey || '',
from: 'out',
text,
messageType: Number(parsed?.messageType || 2),
unread: false,
rawBlobB64: localOutgoingBlobB64,
revisionTimeMs: Number(parsed?.revisionTimeMs || 0),
deleted: Boolean(parsed?.deleted),
});
return true;
} catch {
// ignore local parse failure; server backlog/realtime will reconcile later
return false;
}
};
const notifyUnreadStateUpdated = () => {
window.dispatchEvent(new CustomEvent('shine-unread-state-updated', {
detail: { chatId },
}));
};
const sendDeleteRevision = async (msg) => {
const base = parseBaseKey(msg?.baseKey);
if (!base) return;
const result = await authService.deleteDirectMessage({
login: state.session.login,
toLogin: chatId,
storagePwd: state.session.storagePwdInMemory,
timeMs: base.timeMs,
nonce: base.nonce,
revisionTimeMs: Date.now(),
deleteByRecipient: msg?.from === 'in',
});
addSignedMessageToChat({
chatId,
messageKey: String(msg?.messageKey || ''),
baseKey: String(msg?.baseKey || ''),
deleted: true,
});
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
cancelEditMode({ restoreDraft: true });
}
renderLog(log, chatId, { onOpenActions: handleOpenActions });
};
const sendTextMessage = async (rawText) => {
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
const text = safeText.trim();
if (!text) return;
const editing = activeEdit;
const replying = !editing ? activeReply : null;
const finalText = editing
? `${String(editing?.prefixText || '')}${text}`
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
renderLog(log, chatId, { onOpenActions: handleOpenActions });
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
try {
let result;
if (editing) {
result = await authService.sendDirectMessageRevision({
login: state.session.login,
toLogin: chatId,
text: finalText,
storagePwd: state.session.storagePwdInMemory,
timeMs: editing.timeMs,
nonce: editing.nonce,
revisionTimeMs: Date.now(),
});
} else {
result = await authService.sendDirectMessage({
login: state.session.login,
toLogin: chatId,
text: finalText,
storagePwd: state.session.storagePwdInMemory,
});
markOutgoingSent(tempId, {
messageKey: result?.outgoingKey || '',
baseKey: result?.baseKey || result?.localBaseKey || '',
});
}
const localRevisionApplied = await applyLocalRevision({
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
fallbackMessageKey: result?.outgoingKey || '',
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
});
if (editing) {
cancelEditMode({ restoreDraft: true });
} else if (replying) {
cancelReplyMode({ restoreDraft: false });
}
renderLog(log, chatId, { onOpenActions: handleOpenActions });
if (localRevisionApplied) {
notifyUnreadStateUpdated();
}
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
window.requestAnimationFrame(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }));
window.setTimeout(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }), 220);
if (input) {
try {
input.focus({ preventScroll: true });
} catch {
input.focus();
}
}
addAppLogEntry({
level: 'info',
source: 'outgoing-dm',
message: editing ? `Сообщение изменено для ${chatId}` : `Сообщение отправлено для ${chatId}`,
details: {
toLogin: chatId,
messageId: result?.outgoingKey || '',
deliveredWsSessions: Number(result?.deliveredWsSessions || 0),
deliveredWebPushSessions: Number(result?.deliveredWebPushSessions || 0),
replyToBaseKey: replying?.baseKey || '',
},
});
} catch (e) {
if (input) {
input.value = text;
autoResizeComposer(input);
focusInputToEnd();
}
addChatMessage(chatId, `${activeEdit ? 'Ошибка изменения' : 'Ошибка отправки'}: ${e.message || 'unknown'}`);
addAppLogEntry({
level: 'warn',
source: 'outgoing-dm',
message: activeEdit ? 'Ошибка редактирования личного сообщения' : 'Ошибка отправки личного сообщения',
details: {
toLogin: chatId,
error: e?.message || 'unknown',
},
});
renderLog(log, chatId, { onOpenActions: handleOpenActions });
}
};
const handleOpenActions = (msg, event) => {
const parsed = parseDmTechBlocks(String(msg?.text || ''));
openMessageActionsMenu({
anchorX: Number(event?.clientX || 0),
anchorY: Number(event?.clientY || 0),
messageText: parsed?.displayText || msg?.text || '',
canReply: true,
showReadAloud: isTextToSpeechReady,
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary,
canDelete: (
(msg?.from === 'out' && Number(msg?.messageType || 0) === 2)
|| (msg?.from === 'in' && Number(msg?.messageType || 0) === 1)
),
onReply: async () => {
startReplyMode(msg);
},
onReadAloud: async () => handleReadAloud(msg),
onEdit: async () => {
startEditMode(msg);
},
onDelete: async () => {
openDeleteMessageConfirmModal({
onConfirm: async () => {
try {
await sendDeleteRevision(msg);
} catch (error) {
showToast(`Не удалось удалить сообщение: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1500 });
}
},
});
},
});
};
editCancelBtn?.addEventListener('click', () => {
if (activeEdit) {
cancelEditMode({ restoreDraft: true });
return;
}
cancelReplyMode({ restoreDraft: true });
});
autoResizeComposer(input);
input?.addEventListener('input', () => {
rememberEmojiSelection();
autoResizeComposer(input);
});
input?.addEventListener('select', rememberEmojiSelection);
input?.addEventListener('keyup', rememberEmojiSelection);
input?.addEventListener('click', rememberEmojiSelection);
input?.addEventListener('focus', () => {
rememberEmojiSelection();
inputFocused = true;
syncKeyboardUi();
scrollToLatestMessage(log);
});
input?.addEventListener('blur', () => {
inputFocused = false;
setChatKeyboardOpen(false);
});
emojiToggle?.setAttribute('aria-expanded', 'false');
emojiToggle?.addEventListener('pointerdown', (event) => {
rememberEmojiSelection();
event.preventDefault();
});
emojiToggle?.addEventListener('click', () => {
emojiPickerOpen = !emojiPickerOpen;
emojiSlot.hidden = !emojiPickerOpen;
emojiToggle.setAttribute('aria-expanded', String(emojiPickerOpen));
});
screen.addEventListener('pointerdown', (event) => {
if (!emojiPickerOpen || form.contains(event.target)) return;
closeEmojiPicker();
});
input?.addEventListener('keydown', async (event) => {
if (event.key !== 'Enter') return;
window.requestAnimationFrame(() => autoResizeComposer(input));
});
form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
await openSpeechInputModal({
navigate,
onTextReady: (text) => {
const prev = String(input.value || '').trim();
input.value = prev ? `${prev} ${text}` : text;
autoResizeComposer(input);
},
onSendText: async (text) => sendTextMessage(text),
onSendQueued: () => {
showToast('Сообщение будет отправлено автоматически после распознавания', { timeoutMs: 1000 });
},
});
});
form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
event.preventDefault();
try {
input?.focus({ preventScroll: true });
} catch {
input?.focus();
}
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
const text = String(input.value || '').trim();
if (!text) return;
input.value = '';
autoResizeComposer(input);
closeEmojiPicker();
await sendTextMessage(text);
focusInputToEnd();
});
const handleIncomingChatRefresh = async (event) => {
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
if (updatedChatId !== chatId) return;
preserveComposerSelection(input, () => {
renderLog(log, chatId, { onOpenActions: handleOpenActions, scrollMode: 'latest' });
});
window.requestAnimationFrame(() => scrollToLatestMessage(log));
void sendReadReceiptsForVisible(chatId);
};
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.addEventListener('resize', syncKeyboardUi);
window.addEventListener('resize', syncKeyboardUi);
wrap.append(log, form);
screen.append(wrap);
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead: false,
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
});
if (hasUnreadIncoming) {
window.requestAnimationFrame(() => scrollToUnreadSeparator(log));
window.setTimeout(() => scrollToUnreadSeparator(log), 180);
} else {
window.requestAnimationFrame(() => scrollToLatestMessage(log));
window.setTimeout(() => scrollToLatestMessage(log), 180);
}
window.setTimeout(() => {
if (markChatRead(chatId) > 0) {
notifyUnreadStateUpdated();
}
}, 220);
void sendReadReceiptsForVisible(chatId);
screen.cleanup = () => {
setChatKeyboardOpen(false);
stopAllTelegramEmojiAnimations();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
window.visualViewport?.removeEventListener('resize', syncKeyboardUi);
window.removeEventListener('resize', syncKeyboardUi);
};
return screen;
}
async function sendReadReceiptsForVisible(chatId) {
const pending = getChatMessages(chatId)
.filter((row) => row?.from === 'in' && Number(row?.messageType) === 1 && !row?.readReceiptSent)
.slice(0, 50);
for (const row of pending) {
const ref = parseBaseKey(row.baseKey);
if (!ref) continue;
try {
await authService.sendReadReceipt({
login: state.session.login,
toLogin: ref.fromLogin,
storagePwd: state.session.storagePwdInMemory,
refToLogin: ref.toLogin,
refFromLogin: ref.fromLogin,
refTimeMs: ref.timeMs,
refNonce: ref.nonce,
});
if (row.baseKey) {
markReadReceiptSentByBaseKey(row.baseKey);
} else {
row.readReceiptSent = true;
}
} catch (e) {
addAppLogEntry({
level: 'warn',
source: 'read-receipt',
message: 'Не удалось отправить подтверждение прочтения',
details: { chatId, messageKey: row.messageKey || '', error: e?.message || 'unknown' },
});
}
}
}