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

738 lines
26 KiB
JavaScript
Raw Permalink 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,
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' },
});
}
}
}