SHA256
UI: упростить профиль и обновить UX чатов/шапок
This commit is contained in:
+219
-53
@@ -10,14 +10,108 @@ import {
|
||||
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 openMessageActionsModal({ messageText = '', onReadAloud }) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal" id="chat-message-actions-modal-overlay">
|
||||
<div class="modal-card stack dm-dialog-card dm-message-actions-menu" id="chat-message-actions-modal">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const close = () => {
|
||||
root.innerHTML = '';
|
||||
};
|
||||
|
||||
root.querySelector('#chat-message-actions-modal-overlay')?.addEventListener('click', (event) => {
|
||||
if (event.target?.id === 'chat-message-actions-modal-overlay') close();
|
||||
});
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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('|');
|
||||
@@ -82,7 +176,7 @@ function scrollToLatestMessage(list) {
|
||||
window.setTimeout(apply, 120);
|
||||
}
|
||||
|
||||
function renderLog(list, chatId) {
|
||||
function renderLog(list, chatId, { onOpenActions } = {}) {
|
||||
list.innerHTML = '';
|
||||
const messages = getChatMessages(chatId);
|
||||
let unreadSeparatorInserted = false;
|
||||
@@ -122,6 +216,9 @@ function renderLog(list, chatId) {
|
||||
}
|
||||
|
||||
bubble.append(textNode, metaNode);
|
||||
bubble.addEventListener('click', () => {
|
||||
if (typeof onOpenActions === 'function') onOpenActions(msg);
|
||||
});
|
||||
list.append(bubble);
|
||||
});
|
||||
scrollToLatestMessage(list);
|
||||
@@ -142,20 +239,42 @@ export function render({ navigate, route }) {
|
||||
|
||||
screen.append(
|
||||
renderHeader({
|
||||
title: `Чат: ${contact.name}`,
|
||||
title: `Чат с ${contact.name}`,
|
||||
leftAction: { label: '←', onClick: () => navigate('messages-list') },
|
||||
rightActions: [{
|
||||
label: 'Позвонить',
|
||||
onClick: async () => {
|
||||
try {
|
||||
await startOutgoingCall(chatId);
|
||||
renderLog(log, chatId);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
addSystemChatMessage(chatId, `[Звонок] Ошибка запуска: ${e.message || 'unknown'}`, {
|
||||
from: 'out',
|
||||
kind: 'call-tech',
|
||||
});
|
||||
renderLog(log, chatId);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
}],
|
||||
@@ -168,18 +287,26 @@ export function render({ navigate, route }) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'secondary-btn';
|
||||
btn.type = 'button';
|
||||
btn.textContent = 'Добавить в контакты';
|
||||
btn.textContent = 'Добавить собеседника в контакты';
|
||||
btn.addEventListener('click', async () => {
|
||||
try {
|
||||
await authService.addCloseFriend(chatId);
|
||||
state.contacts = [...new Set([...(state.contacts || []), chatId])];
|
||||
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} добавлен в контакты`,
|
||||
});
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Добавлено';
|
||||
card.remove();
|
||||
} catch (e) {
|
||||
addAppLogEntry({
|
||||
level: 'warn',
|
||||
@@ -202,51 +329,29 @@ export function render({ navigate, route }) {
|
||||
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>
|
||||
<textarea class="input dm-input" name="message" rows="1" placeholder="Введите сообщение" maxlength="2000"></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>
|
||||
`;
|
||||
|
||||
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();
|
||||
const sendTextMessage = async (rawText) => {
|
||||
const text = String(rawText || '').trim();
|
||||
if (!text) return;
|
||||
|
||||
const tempId = addOutgoingPendingMessage(chatId, text);
|
||||
input.value = '';
|
||||
renderLog(log, chatId);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await authService.sendDirectMessage({
|
||||
@@ -259,7 +364,18 @@ export function render({ navigate, route }) {
|
||||
messageKey: result?.outgoingKey || '',
|
||||
baseKey: result?.baseKey || result?.localBaseKey || '',
|
||||
});
|
||||
renderLog(log, chatId);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
addAppLogEntry({
|
||||
level: 'info',
|
||||
source: 'outgoing-dm',
|
||||
@@ -282,13 +398,63 @@ export function render({ navigate, route }) {
|
||||
error: e?.message || 'unknown',
|
||||
},
|
||||
});
|
||||
renderLog(log, chatId);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const input = form.elements.message;
|
||||
autoResizeComposer(input);
|
||||
input?.addEventListener('input', () => 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.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
input.value = '';
|
||||
autoResizeComposer(input);
|
||||
await sendTextMessage(text);
|
||||
});
|
||||
|
||||
wrap.append(log, form);
|
||||
screen.append(wrap);
|
||||
renderLog(log, chatId);
|
||||
renderLog(log, chatId, {
|
||||
onOpenActions: (msg) => openMessageActionsModal({
|
||||
messageText: msg?.text || '',
|
||||
onReadAloud: async () => {
|
||||
if (!isTextToSpeechConfigured(state.entrySettings)) {
|
||||
showTtsMissingConfigDialog(navigate);
|
||||
return;
|
||||
}
|
||||
await speakTextBySettings(String(msg?.text || ''), state.entrySettings);
|
||||
},
|
||||
}),
|
||||
});
|
||||
window.requestAnimationFrame(() => scrollToLatestMessage(log));
|
||||
void sendReadReceiptsForVisible(chatId);
|
||||
return screen;
|
||||
|
||||
Reference in New Issue
Block a user