738 lines
26 KiB
JavaScript
738 lines
26 KiB
JavaScript
import { renderHeader } from '../components/header.js';
|
||
import { directMessages } from '../mock-data.js';
|
||
import {
|
||
addAppLogEntry,
|
||
addChatMessage,
|
||
addSignedMessageToChat,
|
||
addSystemChatMessage,
|
||
addOutgoingPendingMessage,
|
||
getChatMessages,
|
||
markChatRead,
|
||
markOutgoingSent,
|
||
markReadReceiptSentByBaseKey,
|
||
authService,
|
||
setContacts,
|
||
state,
|
||
} from '../state.js';
|
||
import { startOutgoingCall } from '../services/call-service.js';
|
||
import { openSpeechInputModal } from '../components/speech-input-modal.js';
|
||
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||
import { showToast } from '../services/channels-ux.js';
|
||
|
||
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
||
|
||
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 openMessageActionsMenu({
|
||
anchorX = 0,
|
||
anchorY = 0,
|
||
messageText = '',
|
||
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-dialog-card 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>
|
||
<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 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 apply = () => {
|
||
list.scrollTop = list.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 renderLog(list, chatId, { onOpenActions } = {}) {
|
||
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 bubbleKind = String(msg?.kind || '').trim();
|
||
bubble.className = `bubble ${msg.from}${bubbleKind ? ` ${bubbleKind}` : ''}`;
|
||
|
||
const textNode = document.createElement('div');
|
||
textNode.className = 'bubble-text';
|
||
textNode.textContent = msg.text || '';
|
||
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);
|
||
});
|
||
scrollToLatestMessage(list);
|
||
markChatRead(chatId);
|
||
}
|
||
|
||
export function render({ navigate, route }) {
|
||
const chatId = route.params.chatId || 'u1';
|
||
const contact = directMessages.find((d) => d.id === chatId) || {
|
||
id: chatId,
|
||
name: chatId,
|
||
initials: (chatId[0] || '?').toUpperCase(),
|
||
};
|
||
|
||
const screen = document.createElement('section');
|
||
screen.className = 'stack dm-screen dm-chat-screen';
|
||
const isKnownContact = (state.contacts || []).some((x) => String(x || '').toLowerCase() === String(chatId || '').toLowerCase());
|
||
|
||
const handleReadAloud = async (msg) => {
|
||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||
showTtsMissingConfigDialog(navigate);
|
||
return;
|
||
}
|
||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||
};
|
||
|
||
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: 'Позвонить',
|
||
onClick: 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 });
|
||
}
|
||
},
|
||
}],
|
||
})
|
||
);
|
||
|
||
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">
|
||
<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;
|
||
|
||
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 = ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
|
||
if (!localOutgoingBlobB64) return;
|
||
try {
|
||
const parsed = authService.parseSignedMessageBlob(localOutgoingBlobB64);
|
||
addSignedMessageToChat({
|
||
chatId,
|
||
messageKey: fallbackMessageKey || parsed?.messageKey || '',
|
||
baseKey: fallbackBaseKey || parsed?.baseKey || '',
|
||
from: 'out',
|
||
text: parsed?.text || '',
|
||
messageType: Number(parsed?.messageType || 2),
|
||
unread: false,
|
||
rawBlobB64: localOutgoingBlobB64,
|
||
revisionTimeMs: Number(parsed?.revisionTimeMs || 0),
|
||
deleted: Boolean(parsed?.deleted),
|
||
});
|
||
} catch {
|
||
// ignore local parse failure; server backlog/realtime will reconcile later
|
||
}
|
||
};
|
||
|
||
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(),
|
||
});
|
||
applyLocalRevision({
|
||
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
|
||
fallbackMessageKey: result?.outgoingKey || '',
|
||
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
|
||
});
|
||
if (activeEdit?.messageKey && activeEdit.messageKey === String(msg?.messageKey || '')) {
|
||
cancelEditMode({ restoreDraft: true });
|
||
}
|
||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||
};
|
||
|
||
const sendTextMessage = async (rawText) => {
|
||
const text = String(rawText || '').trim();
|
||
if (!text) return;
|
||
const editing = activeEdit;
|
||
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
|
||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||
|
||
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 || '',
|
||
});
|
||
}
|
||
|
||
applyLocalRevision({
|
||
localOutgoingBlobB64: result?.localOutgoingBlobB64 || '',
|
||
fallbackMessageKey: result?.outgoingKey || '',
|
||
fallbackBaseKey: result?.baseKey || result?.localBaseKey || '',
|
||
});
|
||
|
||
if (editing) {
|
||
cancelEditMode({ restoreDraft: true });
|
||
}
|
||
|
||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||
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 || '',
|
||
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
|
||
canDelete: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
|
||
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', () => {
|
||
scrollToLatestMessage(log);
|
||
});
|
||
input?.addEventListener('keydown', async (event) => {
|
||
if (event.key !== 'Enter') return;
|
||
if (event.ctrlKey) {
|
||
event.preventDefault();
|
||
const start = Number(input.selectionStart ?? input.value.length);
|
||
const end = Number(input.selectionEnd ?? input.value.length);
|
||
const value = String(input.value || '');
|
||
input.value = `${value.slice(0, start)}\n${value.slice(end)}`;
|
||
const nextPos = start + 1;
|
||
try {
|
||
input.setSelectionRange(nextPos, nextPos);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
autoResizeComposer(input);
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
const text = String(input.value || '').trim();
|
||
if (!text) return;
|
||
input.value = '';
|
||
autoResizeComposer(input);
|
||
await sendTextMessage(text);
|
||
});
|
||
|
||
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.addEventListener('submit', async (event) => {
|
||
event.preventDefault();
|
||
const text = String(input.value || '').trim();
|
||
if (!text) return;
|
||
input.value = '';
|
||
autoResizeComposer(input);
|
||
await sendTextMessage(text);
|
||
});
|
||
|
||
wrap.append(log, form);
|
||
screen.append(wrap);
|
||
renderLog(log, chatId, { onOpenActions: handleOpenActions });
|
||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||
window.setTimeout(() => scrollToLatestMessage(log), 180);
|
||
void sendReadReceiptsForVisible(chatId);
|
||
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,
|
||
refType: 1,
|
||
});
|
||
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' },
|
||
});
|
||
}
|
||
}
|
||
}
|