Files
SHiNE-server/shine-UI/js/pages/chat-view.js
T

1610 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { renderHeader } from '../components/header.js';
import { attachScrollToBottomButton } from '../components/scroll-to-bottom-button.js';
import { directMessages } from '../mock-data.js';
import {
addAppLogEntry,
addChatMessage,
addSignedMessageToChat,
addSystemChatMessage,
addOutgoingPendingMessage,
deleteConversationMessagesBefore,
getChatMessages,
markChatRead,
markOutgoingSent,
markReadReceiptSentByBaseKey,
normalizeDmChatId,
authService,
setContacts,
state,
} from '../state.js';
import { startOutgoingCall } from '../services/call-service.js';
import {
createEmojiPicker,
createTwemojiAsset,
appendTwemojiText,
hasTwemojiAsset,
stopAllTwemojiAnimations,
} from '../components/emoji-picker.js?v=202607152130';
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
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 = `
<div class="modal" id="chat-delete-message-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">Удалить сообщение?</h3>
<p class="meta-muted">Оно исчезнет и у тебя, и у собеседника.</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="chat-delete-message-no">Нет</button>
<button class="primary-btn" type="button" id="chat-delete-message-yes">Да</button>
</div>
</div>
</div>
`;
const close = () => {
root.innerHTML = '';
};
root.querySelector('#chat-delete-message-no')?.addEventListener('click', close);
root.querySelector('#chat-delete-message-yes')?.addEventListener('click', async () => {
close();
if (typeof onConfirm === 'function') await onConfirm();
});
}
function openChatConfirmModal({
title = 'Подтвердить действие',
text = '',
confirmLabel = 'Да',
confirmKind = 'primary',
onConfirm,
}) {
const root = document.getElementById('modal-root');
if (!root) return;
const confirmClass = confirmKind === 'danger' ? 'destructive-btn' : 'primary-btn';
root.innerHTML = `
<div class="modal" id="chat-confirm-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">${title}</h3>
<p class="meta-muted">${text}</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="chat-confirm-no">Нет</button>
<button class="${confirmClass}" type="button" id="chat-confirm-yes">${confirmLabel}</button>
</div>
</div>
</div>
`;
const close = () => {
root.innerHTML = '';
};
root.querySelector('#chat-confirm-no')?.addEventListener('click', close);
root.querySelector('#chat-confirm-yes')?.addEventListener('click', async () => {
close();
if (typeof onConfirm === 'function') await onConfirm();
});
}
function openDeleteChatConfirmModal({ contactName = '', onConfirm }) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal" id="chat-delete-chat-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">Удалить чат?</h3>
<p class="meta-muted">Удалить пользователя ${contactName} из контактов?</p>
<label class="dm-confirm-check">
<input type="checkbox" id="chat-delete-chat-history" checked />
<span>Также удалить всю историю переписки</span>
</label>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="chat-delete-chat-no">Нет</button>
<button class="destructive-btn" type="button" id="chat-delete-chat-yes">Да</button>
</div>
</div>
</div>
`;
const close = () => {
root.innerHTML = '';
};
root.querySelector('#chat-delete-chat-no')?.addEventListener('click', close);
root.querySelector('#chat-delete-chat-yes')?.addEventListener('click', async () => {
const deleteHistory = Boolean(root.querySelector('#chat-delete-chat-history')?.checked);
close();
if (typeof onConfirm === 'function') {
await onConfirm({ deleteHistory });
}
});
}
function openMessageActionsMenu({
anchorX = 0,
anchorY = 0,
messageText = '',
infoText = '',
canReply = false,
showReadAloud = true,
canEdit = false,
canDelete = false,
onReply,
onReadAloud,
onEdit,
onDelete,
}) {
const root = document.getElementById('modal-root');
if (!root) return;
const menuId = `chat-message-actions-menu-${Date.now()}`;
root.innerHTML = `
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
${canReply ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-reply">Ответить</button>' : ''}
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Скопировать как текст</button>
${showReadAloud ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-read">Прочесть</button>' : ''}
${canEdit ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-edit">Изменить</button>' : ''}
${canDelete ? '<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="msg-action-delete">Удалить</button>' : ''}
${String(infoText || '').trim() ? `<div class="meta-muted">${String(infoText || '').trim()}</div>` : ''}
</div>
</div>
`;
const menu = root.querySelector(`#${menuId}`);
if (!menu) return;
const close = () => {
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
window.removeEventListener('resize', close);
window.removeEventListener('scroll', close, true);
root.innerHTML = '';
};
const onDocumentPointerDown = (event) => {
if (menu.contains(event.target)) return;
close();
};
document.addEventListener('pointerdown', onDocumentPointerDown, true);
window.addEventListener('resize', close);
window.addEventListener('scroll', close, true);
window.requestAnimationFrame(() => {
const menuRect = menu.getBoundingClientRect();
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
const left = Math.min(
Math.max(12, Number(anchorX || 0) - (menuRect.width / 2)),
Math.max(12, viewportWidth - menuRect.width - 12)
);
const top = Math.min(
Math.max(12, Number(anchorY || 0) + 10),
Math.max(12, viewportHeight - menuRect.height - 12)
);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
menu.style.transformOrigin = `${Math.round(Number(anchorX || left) - left)}px top`;
menu.classList.add('is-visible');
});
root.querySelector('#msg-action-copy')?.addEventListener('click', async () => {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(String(messageText || ''));
}
showToast('Сообщение скопированно', { timeoutMs: 1000 });
} catch {
showToast('Не удалось скопировать сообщение', { kind: 'error', timeoutMs: 1200 });
} finally {
close();
}
});
root.querySelector('#msg-action-reply')?.addEventListener('click', async () => {
close();
if (typeof onReply === 'function') await onReply();
});
root.querySelector('#msg-action-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,
onVideoCall,
onInstantVideoCall,
onClearHistory,
onDeleteChat,
}) {
const root = document.getElementById('modal-root');
if (!root) return;
const menuId = `chat-header-actions-menu-${Date.now()}`;
root.innerHTML = `
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">Звонок</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">Звонок с поддержкой видео</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-instant-video-call">Видеозвонок</button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">Очистить историю</button>
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">Удалить чат</button>
</div>
</div>
`;
const menu = root.querySelector(`#${menuId}`);
if (!menu) return;
const close = () => {
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
window.removeEventListener('resize', close);
window.removeEventListener('scroll', close, true);
root.innerHTML = '';
};
const onDocumentPointerDown = (event) => {
if (menu.contains(event.target)) return;
close();
};
document.addEventListener('pointerdown', onDocumentPointerDown, true);
window.addEventListener('resize', close);
window.addEventListener('scroll', close, true);
window.requestAnimationFrame(() => {
const menuRect = menu.getBoundingClientRect();
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
const left = Math.min(
Math.max(12, Number(anchorX || 0) - menuRect.width + 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-video-call')?.addEventListener('click', async () => {
close();
if (typeof onVideoCall === 'function') await onVideoCall();
});
root.querySelector('#chat-menu-instant-video-call')?.addEventListener('click', async () => {
close();
if (typeof onInstantVideoCall === 'function') await onInstantVideoCall();
});
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() {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal" id="chat-tts-missing-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">Озвучка не настроена</h3>
<p class="meta-muted">Функция голосовых инструментов сейчас отключена.</p>
<div class="form-actions-grid">
<button class="primary-btn" type="button" id="chat-tts-ok">OK</button>
</div>
</div>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#chat-tts-ok')?.addEventListener('click', close);
}
function autoResizeComposer(textarea) {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(180, Math.max(42, textarea.scrollHeight))}px`;
}
function createHeaderPhoneHandsetIcon() {
const ns = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('class', 'header-icon-svg header-icon-svg--phone');
const path = document.createElementNS(ns, 'path');
path.setAttribute('d', 'M7.62 10.79a15.1 15.1 0 0 0 5.59 5.59l1.87-1.87a1 1 0 0 1 1.02-.24c1.12.37 2.31.57 3.52.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1C10.61 21 3 13.39 3 4a1 1 0 0 1 1-1h3.37a1 1 0 0 1 1 1c0 1.21.2 2.4.57 3.52a1 1 0 0 1-.24 1.02l-1.08 1.27Z');
path.setAttribute('fill', 'currentColor');
svg.append(path);
return svg;
}
function openConfirmContactModal(targetLogin = '') {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(false);
return new Promise((resolve) => {
root.innerHTML = `
<div class="modal" id="contact-confirm-modal">
<div class="modal-card stack dm-dialog-card">
<h3 class="modal-title">Добавить собеседника</h3>
<p class="meta-muted">Добавить пользователя @${targetLogin} в контакты?</p>
<div class="form-actions-grid">
<button class="secondary-btn" type="button" id="contact-confirm-no">Нет</button>
<button class="primary-btn" type="button" id="contact-confirm-yes">Да</button>
</div>
</div>
</div>
`;
const close = (answer) => {
root.innerHTML = '';
resolve(!!answer);
};
root.querySelector('#contact-confirm-no')?.addEventListener('click', () => close(false));
root.querySelector('#contact-confirm-yes')?.addEventListener('click', () => close(true));
});
}
function parseBaseKey(baseKey) {
const raw = String(baseKey || '').trim();
const parts = raw.split('|');
if (parts.length < 4) return null;
const fromLogin = parts[0] || '';
const toLogin = parts[1] || '';
const timeMs = Number(parts[2] || 0);
const nonce = Number(parts[3] || 0);
if (!fromLogin || !toLogin || !Number.isFinite(timeMs) || !Number.isFinite(nonce)) return null;
return { fromLogin, toLogin, timeMs, nonce };
}
function resolveMessageTimeMs(msg) {
const base = parseBaseKey(msg?.baseKey);
if (base?.timeMs && Number.isFinite(base.timeMs) && base.timeMs > 0) return base.timeMs;
const messageKey = String(msg?.messageKey || '').trim();
if (messageKey) {
const parts = messageKey.split('|');
const timeMs = Number(parts[2] || 0);
if (parts.length >= 4 && Number.isFinite(timeMs) && timeMs > 0) return timeMs;
}
const tempId = String(msg?.tempId || '').trim();
if (tempId.startsWith('tmp-')) {
const ts = Number(tempId.split('-')[1] || 0);
if (Number.isFinite(ts) && ts > 0) return ts;
}
const fallback = Number(msg?.createdAtMs || 0);
if (Number.isFinite(fallback) && fallback > 0) return fallback;
return 0;
}
function formatMessageTime(valueMs) {
const timeMs = Number(valueMs || 0);
if (!Number.isFinite(timeMs) || timeMs <= 0) return '';
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(timeMs));
}
function formatCallDurationCompact(totalSeconds) {
const total = Math.max(0, Math.floor(Number(totalSeconds || 0)));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function buildCallStatusText(callSummary) {
if (!callSummary) return '';
if (callSummary.status === 'completed') {
return `Длительность: ${formatCallDurationCompact(callSummary.durationSec)}`;
}
const reason = String(callSummary.reason || '').trim().toLowerCase();
if (reason === 'offline') return 'Абонент не в сети';
if (reason === 'no_answer') return 'Нет ответа';
if (reason === 'connect_failed') return 'Не удалось установить соединение';
if (reason === 'busy') return 'Абонент занят';
if (reason === 'declined') return 'Звонок отклонён';
return 'Звонок не завершён';
}
function resolveEffectiveReadState(messages, msg) {
if (msg?.from !== 'out') return { isRead: false, readAtMs: 0 };
const explicitReadAtMs = Number(msg?.readAtMs || 0);
if (Number.isFinite(explicitReadAtMs) && explicitReadAtMs > 0) {
return { isRead: true, readAtMs: explicitReadAtMs };
}
if (msg?.secondTick) {
return { isRead: true, readAtMs: 0 };
}
const messageTimeMs = resolveMessageTimeMs(msg);
const hasNewerRead = (messages || []).some((row) => {
if (row?.from !== 'out') return false;
if (resolveMessageTimeMs(row) <= messageTimeMs) return false;
return Boolean(Number(row?.readAtMs || 0) > 0 || row?.secondTick);
});
return { isRead: hasNewerRead, readAtMs: 0 };
}
function resolveDeliveryStatus(messages, msg) {
if (msg?.from !== 'out') return '';
if (resolveEffectiveReadState(messages, msg).isRead) return '✓✓';
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 && hasTwemojiAsset(emoji)) {
textNode.classList.add('bubble-text--twemoji-only');
textNode.append(createTwemojiAsset(emoji, { className: 'chat-twemoji' }));
return;
}
if (appendTwemojiText(textNode, text, { className: 'chat-twemoji chat-twemoji--inline' })) {
textNode.classList.add('bubble-text--twemoji-inline');
}
}
function isTwemojiOnlyMessage(messageText) {
const emoji = String(messageText || '').trim();
return Boolean(emoji && String(messageText || '') === emoji && hasTwemojiAsset(emoji));
}
function scrollToLatestMessageSmart(list, { smoothIfNearBottom = false } = {}) {
if (!list) return;
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
const lastBubble = list.lastElementChild;
const maxScrollTop = Math.max(0, scrollContainer.scrollHeight - scrollContainer.clientHeight);
const distanceToBottom = Math.max(0, maxScrollTop - scrollContainer.scrollTop);
const useSmooth = smoothIfNearBottom && distanceToBottom <= 180;
const apply = (behavior = useSmooth ? 'smooth' : 'auto') => {
if (lastBubble?.scrollIntoView) {
lastBubble.scrollIntoView({ block: 'end', inline: 'nearest', behavior });
}
if (behavior === 'smooth' && typeof scrollContainer.scrollTo === 'function') {
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth' });
} else {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
};
apply(useSmooth ? 'smooth' : 'auto');
window.requestAnimationFrame(() => apply('auto'));
window.setTimeout(() => apply('auto'), useSmooth ? 220 : 90);
}
function scrollToUnreadSeparator(list) {
if (!list) return false;
const separator = list.querySelector('.chat-unread-separator');
if (!separator) return false;
const scrollContainer = list.closest('.screen-content') || list.parentElement || list;
const apply = () => {
if (separator?.scrollIntoView) {
separator.scrollIntoView({ block: 'start', inline: 'nearest' });
}
const bottomSlack = 72;
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - bottomSlack);
};
apply();
window.requestAnimationFrame(apply);
window.requestAnimationFrame(() => window.requestAnimationFrame(apply));
window.setTimeout(apply, 60);
window.setTimeout(apply, 160);
return true;
}
function renderLog(
list,
chatId,
{
onOpenActions,
markAsRead = true,
scrollMode = 'latest',
showUnreadSeparator = true,
unreadSeparatorMessageKey = '',
} = {},
) {
list.innerHTML = '';
const messages = getChatMessages(chatId);
let unreadSeparatorInserted = false;
const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim();
messages.forEach((msg) => {
const isUnreadBoundary = showUnreadSeparator
&& !unreadSeparatorInserted
&& separatorMessageKey
&& String(msg?.messageKey || '').trim() === separatorMessageKey;
if (isUnreadBoundary) {
const sep = document.createElement('div');
sep.className = 'chat-unread-separator';
const label = document.createElement('span');
label.textContent = 'Новые сообщения';
sep.append(label);
list.append(sep);
unreadSeparatorInserted = true;
}
const bubble = document.createElement('div');
const parsedText = parseDmTechBlocks(String(msg?.text || ''));
const autoKind = parsedText.callSummary ? 'call-tech' : '';
const bubbleKind = String(msg?.kind || autoKind || '').trim();
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
const emojiOnly = !parsedText.callSummary && !parsedText.replyRef && isTwemojiOnlyMessage(parsedText.displayText || '');
if (emojiOnly) {
bubble.classList.add('bubble--emoji-only');
}
if (parsedText.callSummary) {
const callCard = document.createElement('div');
callCard.className = 'bubble-call-card';
const callHead = document.createElement('div');
callHead.className = 'bubble-call-head';
const callIcon = document.createElement('span');
callIcon.className = 'bubble-call-icon';
callIcon.classList.add(msg?.from === 'out' ? 'bubble-call-icon--outgoing' : 'bubble-call-icon--incoming');
const callIconPhone = document.createElement('span');
callIconPhone.className = 'bubble-call-icon-phone';
callIconPhone.textContent = '✆';
const callIconArrow = document.createElement('span');
callIconArrow.className = 'bubble-call-icon-arrow';
callIconArrow.textContent = msg?.from === 'out' ? '↑' : '↓';
callIcon.append(callIconPhone, callIconArrow);
const callTitle = document.createElement('span');
callTitle.className = 'bubble-call-title';
callTitle.textContent = 'Звонок';
const callMetaLine = document.createElement('span');
callMetaLine.className = 'bubble-call-line';
callMetaLine.classList.add(
parsedText.callSummary.status === 'completed'
? 'bubble-call-line--success'
: 'bubble-call-line--plain',
);
const callStatus = buildCallStatusText(parsedText.callSummary);
callMetaLine.textContent = callStatus;
callHead.append(callIcon, callTitle, callMetaLine);
callCard.append(callHead);
bubble.append(callCard);
}
const textNode = document.createElement('div');
textNode.className = 'bubble-text';
renderMessageText(textNode, parsedText.displayText || '');
if (parsedText.replyRef) {
const replyTarget = findMessageByBaseKey(messages, parsedText.replyRef.baseKey);
if (replyTarget) {
const replyParsed = parseDmTechBlocks(String(replyTarget?.text || ''));
const replyBox = document.createElement('button');
replyBox.type = 'button';
replyBox.className = 'bubble-reply-preview';
const replyAuthor = document.createElement('div');
replyAuthor.className = 'bubble-reply-preview-author';
replyAuthor.textContent = String(replyTarget?.from === 'out' ? state.session.login : parsedText.replyRef.fromLogin);
const replyText = document.createElement('div');
replyText.className = 'bubble-reply-preview-text';
replyText.textContent = truncatePreviewText(replyParsed.displayText || '', 96);
replyBox.append(replyAuthor, replyText);
replyBox.addEventListener('click', (event) => {
event.stopPropagation();
const targetNode = list.querySelector(`[data-base-key="${CSS.escape(String(replyTarget?.baseKey || ''))}"]`);
if (targetNode?.scrollIntoView) {
targetNode.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' });
}
});
bubble.append(replyBox);
}
}
if (!parsedText.callSummary) {
bubble.append(textNode);
}
const metaNode = document.createElement('div');
metaNode.className = 'bubble-meta';
const timeNode = document.createElement('span');
timeNode.className = 'bubble-time';
timeNode.textContent = formatMessageTime(resolveMessageTimeMs(msg));
metaNode.append(timeNode);
const status = resolveDeliveryStatus(messages, msg);
if (status) {
const statusNode = document.createElement('span');
statusNode.className = 'bubble-status';
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
}
}
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),
});
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'dm-history',
message: 'Не удалось обработать сообщение из истории DM',
details: { chatId, messageKey, error: error?.message || 'unknown' },
});
}
}
}
export function render({ navigate, route, chrome }) {
document.body.classList.add('chat-topbar-overlay');
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());
const hasUnreadIncoming = getChatMessages(chatId).some((msg) => msg?.from === 'in' && msg?.unread);
const UNREAD_SEPARATOR_AUTO_HIDE_MS = 5000;
let historyHasMore = true;
let historyLoading = false;
let historyNextBeforeTimeMs = 0;
let historyNextBeforeMessageKey = '';
let historyBootstrapped = false;
let boundScrollContainer = null;
let unreadSeparatorVisible = hasUnreadIncoming;
let unreadSeparatorHideTimer = null;
let unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || '';
const clearUnreadSeparatorHideTimer = () => {
if (unreadSeparatorHideTimer) {
window.clearTimeout(unreadSeparatorHideTimer);
unreadSeparatorHideTimer = null;
}
};
const renderChatLog = ({ scrollMode = 'latest', markAsRead = true } = {}) => {
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead,
scrollMode,
showUnreadSeparator: unreadSeparatorVisible,
unreadSeparatorMessageKey: unreadSeparatorAnchorMessageKey,
});
if (unreadSeparatorVisible) {
scheduleUnreadSeparatorAutoHide();
}
};
const hideUnreadSeparator = ({ rerender = true } = {}) => {
clearUnreadSeparatorHideTimer();
if (!unreadSeparatorVisible) return;
unreadSeparatorVisible = false;
if (rerender) {
renderChatLog({ scrollMode: 'latest', markAsRead: false });
}
};
const scheduleUnreadSeparatorAutoHide = () => {
clearUnreadSeparatorHideTimer();
if (!unreadSeparatorVisible) return;
unreadSeparatorHideTimer = window.setTimeout(() => {
hideUnreadSeparator({ rerender: true });
}, UNREAD_SEPARATOR_AUTO_HIDE_MS);
};
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 wrap = document.createElement('div');
wrap.className = 'chat-wrap dm-chat-wrap';
const scrollToBottomControl = attachScrollToBottomButton({
scrollContainer: () => boundScrollContainer || wrap,
});
const historyLoader = document.createElement('div');
historyLoader.className = 'dm-history-loader';
historyLoader.hidden = true;
historyLoader.innerHTML = `
<div class="dm-history-loader__pill" aria-live="polite" aria-busy="true">
<span class="dm-history-loader__spinner" aria-hidden="true"></span>
<span class="dm-history-loader__label">Загрузка истории…</span>
</div>
`;
const log = document.createElement('div');
log.className = 'messages-log dm-messages-log';
chrome?.setTopbar(
renderHeader({
title: `Чат с ${contact.name}`,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [
{
title: 'Позвонить',
ariaLabel: 'Позвонить',
className: 'chat-header-icon-btn chat-header-call-btn',
iconNode: createHeaderPhoneHandsetIcon(),
onClick: () => handleStartCall('audio'),
},
{
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('audio'),
onVideoCall: () => handleStartCall('video'),
onInstantVideoCall: () => handleStartCall('instant_video'),
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 = `
<div class="dm-edit-banner" id="chat-edit-banner" hidden>
<div class="dm-edit-banner__text" id="chat-edit-banner-text"></div>
<button class="ghost-btn dm-edit-banner__close" type="button" id="chat-edit-cancel" title="Отменить редактирование">✕</button>
</div>
<div class="emoji-picker-slot" id="chat-emoji-picker-slot" hidden></div>
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000" enterkeyhint="enter"></textarea>
<div class="dm-actions-col">
<button class="ghost-btn dm-emoji-btn" type="button" id="chat-emoji-toggle" aria-label="Эмодзи" title="Эмодзи"><span class="dm-emoji-btn__glyph">☺</span></button>
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
</div>
`;
const input = form.elements.message;
const emojiSlot = form.querySelector('#chat-emoji-picker-slot');
const emojiToggle = form.querySelector('#chat-emoji-toggle');
const editBanner = form.querySelector('#chat-edit-banner');
const editBannerText = form.querySelector('#chat-edit-banner-text');
const editCancelBtn = form.querySelector('#chat-edit-cancel');
let activeEdit = null;
let activeReply = null;
let inputFocused = false;
let emojiPickerOpen = false;
let emojiSelection = null;
const setHistoryLoadingState = (isLoading) => {
historyLoader.hidden = !isLoading;
historyLoader.classList.toggle('is-visible', !!isLoading);
};
const syncComposerLayout = () => {
const height = Number.parseFloat(input?.style.height || '0');
form.classList.toggle('dm-chat-input--multiline', height > 52);
};
const rememberEmojiSelection = () => {
if (!input) return;
const valueLength = String(input.value || '').length;
const start = Math.max(0, Math.min(valueLength, Number(input.selectionStart ?? valueLength)));
const end = Math.max(start, Math.min(valueLength, Number(input.selectionEnd ?? start)));
emojiSelection = { start, end };
};
const closeEmojiPicker = () => {
if (!emojiPickerOpen) return;
emojiPickerOpen = false;
stopAllTwemojiAnimations();
emojiSlot.hidden = true;
emojiToggle?.setAttribute('aria-expanded', 'false');
};
// На сенсорных устройствах фокус в textarea поднимает клавиатуру поверх пикера —
// поэтому при вставке эмодзи фокусируем поле только там, где есть «точный» указатель.
const isFinePointer = window.matchMedia?.('(hover: hover) and (pointer: fine)')?.matches ?? true;
const insertEmoji = (emoji) => {
if (!input) return;
const value = String(input.value || '');
const start = emojiSelection?.start ?? Number(input.selectionStart ?? value.length);
const end = emojiSelection?.end ?? Number(input.selectionEnd ?? value.length);
input.value = `${value.slice(0, start)}${emoji}${value.slice(end)}`;
const nextPosition = start + emoji.length;
emojiSelection = { start: nextPosition, end: nextPosition };
if (isFinePointer) {
input.focus();
try {
input.setSelectionRange(nextPosition, nextPosition);
} catch {
// Ignore browsers that do not expose textarea selection during focus changes.
}
}
autoResizeComposer(input);
syncComposerLayout();
};
if (emojiSlot) {
emojiSlot.append(createEmojiPicker({ onSelect: insertEmoji }));
}
const syncEditBanner = () => {
if (!editBanner || !editBannerText) return;
if (!activeEdit && !activeReply) {
editBanner.hidden = true;
editBannerText.textContent = '';
return;
}
editBanner.hidden = false;
if (activeEdit) {
editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`;
return;
}
editBannerText.textContent = `Ответить\n${truncatePreviewText(activeReply?.previewText || '', 110)}`;
};
const focusInputToEnd = () => {
if (!input) return;
input.focus();
try {
const nextPos = String(input.value || '').length;
input.setSelectionRange(nextPos, nextPos);
} catch {
// ignore
}
};
const cancelEditMode = ({ restoreDraft = true } = {}) => {
if (!activeEdit) return;
const draftToRestore = activeEdit.draftBeforeEdit;
activeEdit = null;
syncEditBanner();
if (restoreDraft && input) {
input.value = draftToRestore || '';
autoResizeComposer(input);
syncComposerLayout();
focusInputToEnd();
}
};
const cancelReplyMode = ({ restoreDraft = true } = {}) => {
if (!activeReply) return;
activeReply = null;
syncEditBanner();
if (restoreDraft && input) {
autoResizeComposer(input);
syncComposerLayout();
focusInputToEnd();
}
};
const startEditMode = (msg) => {
const base = parseBaseKey(msg?.baseKey);
if (!base || msg?.from !== 'out') return;
const parsed = parseDmTechBlocks(String(msg?.text || ''));
const currentDraft = String(input?.value || '');
activeReply = null;
activeEdit = {
messageKey: String(msg?.messageKey || ''),
originalText: String(parsed?.displayText || ''),
prefixText: String(parsed?.prefixText || ''),
draftBeforeEdit: activeEdit ? String(activeEdit.draftBeforeEdit || '') : currentDraft,
timeMs: base.timeMs,
nonce: base.nonce,
};
syncEditBanner();
if (input) {
input.value = String(parsed?.visibleText || '');
autoResizeComposer(input);
syncComposerLayout();
focusInputToEnd();
}
};
const startReplyMode = (msg) => {
const base = parseBaseKey(msg?.baseKey);
if (!base) return;
const parsed = parseDmTechBlocks(String(msg?.text || ''));
activeEdit = null;
activeReply = {
baseKey: String(msg?.baseKey || ''),
fromLogin: base.fromLogin,
toLogin: base.toLogin,
previewText: String(parsed?.displayText || parsed?.visibleText || ''),
};
syncEditBanner();
focusInputToEnd();
};
const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
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 });
}
renderChatLog({ scrollMode: 'latest', markAsRead: false });
};
const sendTextMessage = async (rawText) => {
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
const text = safeText.trim();
if (!text) return;
hideUnreadSeparator({ rerender: false });
const editing = activeEdit;
const replying = !editing ? activeReply : null;
const finalText = editing
? `${String(editing?.prefixText || '')}${text}`
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
renderChatLog({ scrollMode: 'latest', markAsRead: false });
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
try {
let result;
if (editing) {
result = await authService.sendDirectMessageRevision({
login: state.session.login,
toLogin: chatId,
text: finalText,
storagePwd: state.session.storagePwdInMemory,
timeMs: editing.timeMs,
nonce: editing.nonce,
revisionTimeMs: Date.now(),
});
} else {
result = await authService.sendDirectMessage({
login: state.session.login,
toLogin: chatId,
text: finalText,
storagePwd: state.session.storagePwdInMemory,
});
markOutgoingSent(tempId, {
messageKey: result?.outgoingKey || '',
baseKey: result?.baseKey || result?.localBaseKey || '',
});
}
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 });
}
renderChatLog({ scrollMode: 'latest', markAsRead: false });
if (localRevisionApplied) {
notifyUnreadStateUpdated();
}
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
window.requestAnimationFrame(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }));
window.setTimeout(() => scrollToLatestMessageSmart(log, { smoothIfNearBottom: true }), 220);
if (input) {
try {
input.focus({ preventScroll: true });
} catch {
input.focus();
}
}
addAppLogEntry({
level: 'info',
source: 'outgoing-dm',
message: editing ? `Сообщение изменено для ${chatId}` : `Сообщение отправлено для ${chatId}`,
details: {
toLogin: chatId,
messageId: result?.outgoingKey || '',
deliveredWsSessions: Number(result?.deliveredWsSessions || 0),
deliveredWebPushSessions: Number(result?.deliveredWebPushSessions || 0),
replyToBaseKey: replying?.baseKey || '',
},
});
} catch (e) {
if (input) {
input.value = text;
autoResizeComposer(input);
syncComposerLayout();
focusInputToEnd();
}
addChatMessage(chatId, `${activeEdit ? 'Ошибка изменения' : 'Ошибка отправки'}: ${e.message || 'unknown'}`);
addAppLogEntry({
level: 'warn',
source: 'outgoing-dm',
message: activeEdit ? 'Ошибка редактирования личного сообщения' : 'Ошибка отправки личного сообщения',
details: {
toLogin: chatId,
error: e?.message || 'unknown',
},
});
renderChatLog({ scrollMode: 'latest', markAsRead: false });
}
};
const handleOpenActions = (msg, event) => {
const parsed = parseDmTechBlocks(String(msg?.text || ''));
const readState = resolveEffectiveReadState(getChatMessages(chatId), msg);
openMessageActionsMenu({
anchorX: Number(event?.clientX || 0),
anchorY: Number(event?.clientY || 0),
messageText: parsed?.displayText || msg?.text || '',
infoText: (msg?.from === 'out' && readState.readAtMs > 0)
? `Прочитано: ${formatMessageTime(readState.readAtMs)}`
: '',
canReply: true,
showReadAloud: isTextToSpeechReady,
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary,
canDelete: (
(msg?.from === 'out' && Number(msg?.messageType || 0) === 2)
|| (msg?.from === 'in' && Number(msg?.messageType || 0) === 1)
),
onReply: async () => {
startReplyMode(msg);
},
onReadAloud: async () => handleReadAloud(msg),
onEdit: async () => {
startEditMode(msg);
},
onDelete: async () => {
openDeleteMessageConfirmModal({
onConfirm: async () => {
try {
await sendDeleteRevision(msg);
} catch (error) {
showToast(`Не удалось удалить сообщение: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1500 });
}
},
});
},
});
};
const loadHistoryPage = async ({ preserveScroll = false } = {}) => {
if (historyLoading) return;
if (historyBootstrapped && !historyHasMore) return;
historyLoading = true;
setHistoryLoadingState(true);
const scrollContainer = boundScrollContainer || log.closest('.screen-content') || wrap.parentElement || wrap;
const prevScrollTop = Number(scrollContainer?.scrollTop || 0);
const prevScrollHeight = Number(scrollContainer?.scrollHeight || 0);
try {
const payload = await authService.getDirectMessages({
peerLogin: chatId,
limit: 50,
beforeTimeMs: historyBootstrapped ? historyNextBeforeTimeMs : 0,
beforeMessageKey: historyBootstrapped ? historyNextBeforeMessageKey : '',
});
await mergeDirectMessagesPage(chatId, payload?.messages || []);
historyHasMore = Boolean(payload?.hasMore);
historyNextBeforeTimeMs = Number(payload?.nextBeforeTimeMs || 0);
historyNextBeforeMessageKey = String(payload?.nextBeforeMessageKey || '').trim();
historyBootstrapped = true;
renderChatLog({ markAsRead: false, scrollMode: preserveScroll ? 'preserve' : 'latest' });
if (preserveScroll) {
window.requestAnimationFrame(() => {
const nextHeight = Number(scrollContainer?.scrollHeight || 0);
scrollContainer.scrollTop = Math.max(0, prevScrollTop + (nextHeight - prevScrollHeight));
});
} else {
window.requestAnimationFrame(() => scrollToLatestMessage(log));
}
void sendReadReceiptsForVisible(chatId);
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'dm-history',
message: 'Не удалось загрузить историю DM',
details: { chatId, error: error?.message || 'unknown' },
});
} finally {
historyLoading = false;
setHistoryLoadingState(false);
}
};
const handleHistoryScroll = () => {
const scrollContainer = boundScrollContainer || log.closest('.screen-content') || wrap.parentElement || wrap;
if (!scrollContainer) return;
if (Number(scrollContainer.scrollTop || 0) > 120) return;
if (!historyBootstrapped || !historyHasMore || historyLoading) return;
void loadHistoryPage({ preserveScroll: true });
};
editCancelBtn?.addEventListener('click', () => {
if (activeEdit) {
cancelEditMode({ restoreDraft: true });
return;
}
cancelReplyMode({ restoreDraft: true });
});
autoResizeComposer(input);
syncComposerLayout();
input?.addEventListener('input', () => {
rememberEmojiSelection();
autoResizeComposer(input);
syncComposerLayout();
});
input?.addEventListener('select', rememberEmojiSelection);
input?.addEventListener('keyup', rememberEmojiSelection);
input?.addEventListener('click', rememberEmojiSelection);
input?.addEventListener('focus', () => {
rememberEmojiSelection();
inputFocused = true;
window.requestAnimationFrame(() => {
if (inputFocused) scrollToLatestMessage(log);
});
});
input?.addEventListener('blur', () => {
inputFocused = false;
});
emojiToggle?.setAttribute('aria-expanded', 'false');
emojiToggle?.addEventListener('pointerdown', (event) => {
rememberEmojiSelection();
event.preventDefault();
});
emojiToggle?.addEventListener('click', () => {
emojiPickerOpen = !emojiPickerOpen;
emojiSlot.hidden = !emojiPickerOpen;
emojiToggle.setAttribute('aria-expanded', String(emojiPickerOpen));
// Либо клавиатура, либо пикер. На тач-устройствах при
// открытии пикера прячем клавиатуру, чтобы поле ввода оставалось видно.
if (emojiPickerOpen && !isFinePointer) input?.blur();
});
screen.addEventListener('pointerdown', (event) => {
if (!emojiPickerOpen || form.contains(event.target)) return;
closeEmojiPicker();
});
form.querySelector('.dm-send-btn')?.addEventListener('pointerdown', (event) => {
event.preventDefault();
try {
input?.focus({ preventScroll: true });
} catch {
input?.focus();
}
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
const text = String(input.value || '').trim();
if (!text) return;
input.value = '';
autoResizeComposer(input);
syncComposerLayout();
closeEmojiPicker();
await sendTextMessage(text);
focusInputToEnd();
});
const handleIncomingChatRefresh = async (event) => {
const updatedChatId = normalizeDmChatId(event?.detail?.chatId);
if (updatedChatId !== chatId) return;
if (Number(event?.detail?.messageType || 0) === 1) {
if (!unreadSeparatorVisible) {
unreadSeparatorAnchorMessageKey = getChatMessages(chatId).find((msg) => msg?.from === 'in' && msg?.unread)?.messageKey || unreadSeparatorAnchorMessageKey;
unreadSeparatorVisible = Boolean(unreadSeparatorAnchorMessageKey);
}
scheduleUnreadSeparatorAutoHide();
}
preserveComposerSelection(input, () => {
renderChatLog({ scrollMode: 'latest' });
});
window.requestAnimationFrame(() => scrollToLatestMessage(log));
void sendReadReceiptsForVisible(chatId);
};
window.addEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
wrap.append(historyLoader, log);
screen.append(wrap);
chrome?.setComposer(form);
renderLog(log, chatId, {
onOpenActions: handleOpenActions,
markAsRead: false,
scrollMode: hasUnreadIncoming ? 'unread' : 'latest',
showUnreadSeparator: unreadSeparatorVisible,
});
if (unreadSeparatorVisible) {
scheduleUnreadSeparatorAutoHide();
}
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 = () => {
scrollToBottomControl.cleanup();
hideUnreadSeparator({ rerender: false });
stopAllTwemojiAnimations();
window.removeEventListener('shine-chat-messages-updated', handleIncomingChatRefresh);
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
clearUnreadSeparatorHideTimer();
document.body.classList.remove('chat-topbar-overlay');
chrome?.setComposer(null);
};
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' },
});
}
}
}