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

1140 lines
41 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 { 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 { 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 { 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 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 = '',
showReadAloud = true,
canEdit = false,
canDelete = false,
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}">
<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>' : ''}
</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-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 = `
<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-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-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 = `
<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="secondary-btn" type="button" id="chat-tts-no">Нет</button>
<button class="primary-btn" type="button" id="chat-tts-yes">Да</button>
</div>
</div>
</div>
`;
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 = `
<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 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 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}` : ''}`;
const textNode = document.createElement('div');
textNode.className = 'bubble-text';
textNode.textContent = parsedText.displayText || '';
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);
});
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 = `
<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>
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="12000"></textarea>
<div class="dm-actions-col">
${isSpeechToTextReady ? '<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input" title="Голосовой ввод">🎤</button>' : ''}
<button class="primary-btn dm-send-btn dm-send-icon-btn" type="submit" title="Отправить">➤</button>
</div>
`;
const input = form.elements.message;
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 inputFocused = false;
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 syncEditBanner = () => {
if (!editBanner || !editBannerText) return;
if (!activeEdit) {
editBanner.hidden = true;
editBannerText.textContent = '';
return;
}
editBanner.hidden = false;
editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 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 startEditMode = (msg) => {
const base = parseBaseKey(msg?.baseKey);
if (!base || msg?.from !== 'out') return;
const currentDraft = String(input?.value || '');
activeEdit = {
messageKey: String(msg?.messageKey || ''),
originalText: String(msg?.text || ''),
draftBeforeEdit: activeEdit ? String(activeEdit.draftBeforeEdit || '') : currentDraft,
timeMs: base.timeMs,
nonce: base.nonce,
};
syncEditBanner();
if (input) {
input.value = String(msg?.text || '');
autoResizeComposer(input);
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 text = sanitizeUserDmTextForSend(String(rawText || '')).trim();
if (!text) return;
const editing = activeEdit;
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,
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,
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 });
}
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),
},
});
} 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) => {
openMessageActionsMenu({
anchorX: Number(event?.clientX || 0),
anchorY: Number(event?.clientY || 0),
messageText: msg?.text || '',
showReadAloud: isTextToSpeechReady,
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
canDelete: (
(msg?.from === 'out' && Number(msg?.messageType || 0) === 2)
|| (msg?.from === 'in' && Number(msg?.messageType || 0) === 1)
),
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', () => {
cancelEditMode({ restoreDraft: true });
});
autoResizeComposer(input);
input?.addEventListener('input', () => autoResizeComposer(input));
input?.addEventListener('focus', () => {
inputFocused = true;
syncKeyboardUi();
scrollToLatestMessage(log);
});
input?.addEventListener('blur', () => {
inputFocused = false;
setChatKeyboardOpen(false);
});
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);
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);
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' },
});
}
}
}