SHA256
329 lines
11 KiB
JavaScript
329 lines
11 KiB
JavaScript
import { renderHeader } from '../components/header.js';
|
|
import { directMessages } from '../mock-data.js';
|
|
import {
|
|
addAppLogEntry,
|
|
addChatMessage,
|
|
addSystemChatMessage,
|
|
addOutgoingPendingMessage,
|
|
getChatMessages,
|
|
markChatRead,
|
|
markOutgoingSent,
|
|
markReadReceiptSentByBaseKey,
|
|
authService,
|
|
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';
|
|
|
|
export const pageMeta = { id: 'chat-view', title: 'Чат' };
|
|
|
|
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 scrollToLatestMessage(list) {
|
|
if (!list) return;
|
|
const apply = () => {
|
|
list.scrollTop = list.scrollHeight;
|
|
};
|
|
apply();
|
|
window.requestAnimationFrame(apply);
|
|
window.setTimeout(apply, 0);
|
|
window.setTimeout(apply, 120);
|
|
}
|
|
|
|
function renderLog(list, chatId) {
|
|
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 || '';
|
|
|
|
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(textNode, metaNode);
|
|
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());
|
|
|
|
screen.append(
|
|
renderHeader({
|
|
title: `Чат: ${contact.name}`,
|
|
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
|
rightActions: [{
|
|
label: 'Позвонить',
|
|
onClick: async () => {
|
|
try {
|
|
await startOutgoingCall(chatId);
|
|
renderLog(log, chatId);
|
|
} catch (e) {
|
|
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
|
from: 'out',
|
|
kind: 'call-tech',
|
|
});
|
|
renderLog(log, chatId);
|
|
}
|
|
},
|
|
}],
|
|
})
|
|
);
|
|
|
|
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 {
|
|
await authService.addCloseFriend(chatId);
|
|
state.contacts = [...new Set([...(state.contacts || []), chatId])];
|
|
addAppLogEntry({
|
|
level: 'info',
|
|
source: 'contacts',
|
|
message: `Пользователь ${chatId} добавлен в контакты`,
|
|
});
|
|
btn.disabled = true;
|
|
btn.textContent = 'Добавлено';
|
|
} catch (e) {
|
|
addAppLogEntry({
|
|
level: 'warn',
|
|
source: 'contacts',
|
|
message: 'Не удалось добавить пользователя в контакты',
|
|
details: { login: chatId, error: e?.message || 'unknown' },
|
|
});
|
|
}
|
|
});
|
|
card.append(btn);
|
|
screen.append(card);
|
|
}
|
|
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'chat-wrap dm-chat-wrap';
|
|
|
|
const log = document.createElement('div');
|
|
log.className = 'messages-log dm-messages-log';
|
|
|
|
const form = document.createElement('form');
|
|
form.className = 'chat-input dm-chat-input';
|
|
form.innerHTML = `
|
|
<input class="input dm-input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" />
|
|
<button class="ghost-btn dm-voice-btn" type="button" id="chat-voice-input">🎤</button>
|
|
<button class="ghost-btn dm-voice-btn" type="button" id="chat-read-aloud">🔊</button>
|
|
<button class="primary-btn dm-send-btn" type="submit">Отправить</button>
|
|
`;
|
|
|
|
form.querySelector('#chat-voice-input')?.addEventListener('click', async () => {
|
|
const input = form.elements.message;
|
|
await openSpeechInputModal({
|
|
navigate,
|
|
onTextReady: (text) => {
|
|
const prev = String(input.value || '').trim();
|
|
input.value = prev ? `${prev} ${text}` : text;
|
|
},
|
|
});
|
|
});
|
|
|
|
form.querySelector('#chat-read-aloud')?.addEventListener('click', async () => {
|
|
const input = form.elements.message;
|
|
const text = String(input.value || '').trim();
|
|
if (!text) {
|
|
window.alert('Введите текст для озвучки.');
|
|
return;
|
|
}
|
|
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
|
const goSettings = window.confirm('Озвучка не настроена. Перейти в настройки инструментов?');
|
|
if (goSettings) navigate('tools-settings-view');
|
|
return;
|
|
}
|
|
try {
|
|
await speakTextBySettings(text, state.entrySettings);
|
|
} catch (error) {
|
|
window.alert(`Ошибка озвучки: ${error?.message || 'unknown'}`);
|
|
}
|
|
});
|
|
|
|
form.addEventListener('submit', async (event) => {
|
|
event.preventDefault();
|
|
const input = form.elements.message;
|
|
const text = input.value.trim();
|
|
if (!text) return;
|
|
|
|
const tempId = addOutgoingPendingMessage(chatId, text);
|
|
input.value = '';
|
|
renderLog(log, chatId);
|
|
|
|
try {
|
|
const 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 || '',
|
|
});
|
|
renderLog(log, chatId);
|
|
addAppLogEntry({
|
|
level: 'info',
|
|
source: 'outgoing-dm',
|
|
message: `Сообщение отправлено для ${chatId}`,
|
|
details: {
|
|
toLogin: chatId,
|
|
messageId: result?.outgoingKey || '',
|
|
deliveredWsSessions: Number(result?.deliveredWsSessions || 0),
|
|
deliveredWebPushSessions: Number(result?.deliveredWebPushSessions || 0),
|
|
},
|
|
});
|
|
} catch (e) {
|
|
addChatMessage(chatId, `Ошибка отправки: ${e.message || 'unknown'}`);
|
|
addAppLogEntry({
|
|
level: 'warn',
|
|
source: 'outgoing-dm',
|
|
message: 'Ошибка отправки личного сообщения',
|
|
details: {
|
|
toLogin: chatId,
|
|
error: e?.message || 'unknown',
|
|
},
|
|
});
|
|
renderLog(log, chatId);
|
|
}
|
|
});
|
|
|
|
wrap.append(log, form);
|
|
screen.append(wrap);
|
|
renderLog(log, chatId);
|
|
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
|
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' },
|
|
});
|
|
}
|
|
}
|
|
}
|