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

282 lines
8.9 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,
addSystemChatMessage,
addOutgoingPendingMessage,
getChatMessages,
markChatRead,
markOutgoingSent,
markReadReceiptSentByBaseKey,
authService,
state,
} from '../state.js';
import { startOutgoingCall } from '../services/call-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 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);
});
list.scrollTop = list.scrollHeight;
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';
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';
const log = document.createElement('div');
log.className = 'messages-log';
const form = document.createElement('form');
form.className = 'chat-input';
form.innerHTML = `
<input class="input" type="text" name="message" placeholder="Введите сообщение" maxlength="300" />
<button class="primary-btn" type="submit">Отправить</button>
`;
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);
}
});
renderLog(log, chatId);
void sendReadReceiptsForVisible(chatId);
wrap.append(log, form);
screen.append(wrap);
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' },
});
}
}
}