Добавить ответы в DM и обновить UI чатов

This commit is contained in:
AidarKC
2026-07-10 19:27:49 +04:00
parent 0d2d7b6dc5
commit c9cfb394d7
10 changed files with 526 additions and 15 deletions
+103 -11
View File
@@ -20,7 +20,7 @@ import { startOutgoingCall } from '../services/call-service.js';
import { openSpeechInputModal } from '../components/speech-input-modal.js';
import { isSpeechToTextConfigured, isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
import { showToast } from '../services/channels-ux.js';
import { parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
@@ -32,6 +32,12 @@ function truncatePreviewText(value, maxLen = 72) {
return `${normalized.slice(0, Math.max(0, maxLen - 1)).trimEnd()}`;
}
function findMessageByBaseKey(messages, baseKey) {
const normalized = String(baseKey || '').trim();
if (!normalized) return null;
return messages.find((row) => String(row?.baseKey || '').trim() === normalized) || null;
}
function openDeleteMessageConfirmModal({ onConfirm }) {
const root = document.getElementById('modal-root');
if (!root) return;
@@ -131,9 +137,11 @@ function openMessageActionsMenu({
anchorX = 0,
anchorY = 0,
messageText = '',
canReply = false,
showReadAloud = true,
canEdit = false,
canDelete = false,
onReply,
onReadAloud,
onEdit,
onDelete,
@@ -145,6 +153,7 @@ function openMessageActionsMenu({
root.innerHTML = `
<div class="dm-floating-menu-layer" id="chat-message-actions-layer">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
${canReply ? '<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-reply">Ответить</button>' : ''}
<button class="secondary-btn dm-message-action-btn" type="button" id="msg-action-copy">Скопировать как текст</button>
${showReadAloud ? '<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>' : ''}
@@ -203,6 +212,11 @@ function openMessageActionsMenu({
}
});
root.querySelector('#msg-action-reply')?.addEventListener('click', async () => {
close();
if (typeof onReply === 'function') await onReply();
});
root.querySelector('#msg-action-read')?.addEventListener('click', async () => {
close();
if (typeof onReadAloud === 'function') await onReadAloud();
@@ -492,6 +506,33 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode
const textNode = document.createElement('div');
textNode.className = 'bubble-text';
textNode.textContent = parsedText.displayText || '';
if (parsedText.replyRef) {
const replyTarget = findMessageByBaseKey(messages, parsedText.replyRef.baseKey);
if (replyTarget) {
const replyParsed = parseDmTechBlocks(String(replyTarget?.text || ''));
const replyBox = document.createElement('button');
replyBox.type = 'button';
replyBox.className = 'bubble-reply-preview';
const replyAuthor = document.createElement('div');
replyAuthor.className = 'bubble-reply-preview-author';
replyAuthor.textContent = String(replyTarget?.from === 'out' ? state.session.login : parsedText.replyRef.fromLogin);
const replyText = document.createElement('div');
replyText.className = 'bubble-reply-preview-text';
replyText.textContent = truncatePreviewText(replyParsed.displayText || '', 96);
replyBox.append(replyAuthor, replyText);
replyBox.addEventListener('click', (event) => {
event.stopPropagation();
const targetNode = list.querySelector(`[data-base-key="${CSS.escape(String(replyTarget?.baseKey || ''))}"]`);
if (targetNode?.scrollIntoView) {
targetNode.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' });
}
});
bubble.append(replyBox);
}
}
bubble.append(textNode);
const metaNode = document.createElement('div');
@@ -523,6 +564,7 @@ function renderLog(list, chatId, { onOpenActions, markAsRead = true, scrollMode
bubble.addEventListener('click', (event) => {
if (typeof onOpenActions === 'function') onOpenActions(msg, event);
});
bubble.dataset.baseKey = String(msg?.baseKey || '');
list.append(bubble);
});
if (scrollMode === 'unread' && !scrollToUnreadSeparator(list)) {
@@ -764,6 +806,7 @@ export function render({ navigate, route }) {
const editBannerText = form.querySelector('#chat-edit-banner-text');
const editCancelBtn = form.querySelector('#chat-edit-cancel');
let activeEdit = null;
let activeReply = null;
let inputFocused = false;
const baseViewportHeight = Math.max(window.visualViewport?.height || 0, window.innerHeight || 0);
@@ -778,13 +821,17 @@ export function render({ navigate, route }) {
const syncEditBanner = () => {
if (!editBanner || !editBannerText) return;
if (!activeEdit) {
if (!activeEdit && !activeReply) {
editBanner.hidden = true;
editBannerText.textContent = '';
return;
}
editBanner.hidden = false;
editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`;
if (activeEdit) {
editBannerText.textContent = `Редактируем сообщение: ${truncatePreviewText(activeEdit.originalText, 110)}`;
return;
}
editBannerText.textContent = `Ответить\n${truncatePreviewText(activeReply?.previewText || '', 110)}`;
};
const focusInputToEnd = () => {
@@ -810,25 +857,53 @@ export function render({ navigate, route }) {
}
};
const cancelReplyMode = ({ restoreDraft = true } = {}) => {
if (!activeReply) return;
activeReply = null;
syncEditBanner();
if (restoreDraft && input) {
autoResizeComposer(input);
focusInputToEnd();
}
};
const startEditMode = (msg) => {
const base = parseBaseKey(msg?.baseKey);
if (!base || msg?.from !== 'out') return;
const parsed = parseDmTechBlocks(String(msg?.text || ''));
const currentDraft = String(input?.value || '');
activeReply = null;
activeEdit = {
messageKey: String(msg?.messageKey || ''),
originalText: String(msg?.text || ''),
originalText: String(parsed?.displayText || ''),
prefixText: String(parsed?.prefixText || ''),
draftBeforeEdit: activeEdit ? String(activeEdit.draftBeforeEdit || '') : currentDraft,
timeMs: base.timeMs,
nonce: base.nonce,
};
syncEditBanner();
if (input) {
input.value = String(msg?.text || '');
input.value = String(parsed?.visibleText || '');
autoResizeComposer(input);
focusInputToEnd();
}
};
const startReplyMode = (msg) => {
const base = parseBaseKey(msg?.baseKey);
if (!base) return;
const parsed = parseDmTechBlocks(String(msg?.text || ''));
activeEdit = null;
activeReply = {
baseKey: String(msg?.baseKey || ''),
fromLogin: base.fromLogin,
toLogin: base.toLogin,
previewText: String(parsed?.displayText || parsed?.visibleText || ''),
};
syncEditBanner();
focusInputToEnd();
};
const applyLocalRevision = async ({ localOutgoingBlobB64, fallbackMessageKey = '', fallbackBaseKey = '' }) => {
if (!localOutgoingBlobB64) return;
try {
@@ -892,9 +967,14 @@ export function render({ navigate, route }) {
};
const sendTextMessage = async (rawText) => {
const text = sanitizeUserDmTextForSend(String(rawText || '')).trim();
const safeText = sanitizeUserDmTextForSend(String(rawText || ''));
const text = safeText.trim();
if (!text) return;
const editing = activeEdit;
const replying = !editing ? activeReply : null;
const finalText = editing
? `${String(editing?.prefixText || '')}${text}`
: `${replying ? buildDmReplyTechBlock({ baseKey: replying.baseKey }) : ''}${text}`;
const tempId = editing ? '' : addOutgoingPendingMessage(chatId, text);
renderLog(log, chatId, { onOpenActions: handleOpenActions });
scrollToLatestMessageSmart(log, { smoothIfNearBottom: true });
@@ -905,7 +985,7 @@ export function render({ navigate, route }) {
result = await authService.sendDirectMessageRevision({
login: state.session.login,
toLogin: chatId,
text,
text: finalText,
storagePwd: state.session.storagePwdInMemory,
timeMs: editing.timeMs,
nonce: editing.nonce,
@@ -915,7 +995,7 @@ export function render({ navigate, route }) {
result = await authService.sendDirectMessage({
login: state.session.login,
toLogin: chatId,
text,
text: finalText,
storagePwd: state.session.storagePwdInMemory,
});
markOutgoingSent(tempId, {
@@ -932,6 +1012,8 @@ export function render({ navigate, route }) {
if (editing) {
cancelEditMode({ restoreDraft: true });
} else if (replying) {
cancelReplyMode({ restoreDraft: false });
}
renderLog(log, chatId, { onOpenActions: handleOpenActions });
@@ -957,6 +1039,7 @@ export function render({ navigate, route }) {
messageId: result?.outgoingKey || '',
deliveredWsSessions: Number(result?.deliveredWsSessions || 0),
deliveredWebPushSessions: Number(result?.deliveredWebPushSessions || 0),
replyToBaseKey: replying?.baseKey || '',
},
});
} catch (e) {
@@ -980,16 +1063,21 @@ export function render({ navigate, route }) {
};
const handleOpenActions = (msg, event) => {
const parsed = parseDmTechBlocks(String(msg?.text || ''));
openMessageActionsMenu({
anchorX: Number(event?.clientX || 0),
anchorY: Number(event?.clientY || 0),
messageText: msg?.text || '',
messageText: parsed?.displayText || msg?.text || '',
canReply: true,
showReadAloud: isTextToSpeechReady,
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2,
canEdit: msg?.from === 'out' && Number(msg?.messageType || 0) === 2 && !parsed?.callSummary,
canDelete: (
(msg?.from === 'out' && Number(msg?.messageType || 0) === 2)
|| (msg?.from === 'in' && Number(msg?.messageType || 0) === 1)
),
onReply: async () => {
startReplyMode(msg);
},
onReadAloud: async () => handleReadAloud(msg),
onEdit: async () => {
startEditMode(msg);
@@ -1009,7 +1097,11 @@ export function render({ navigate, route }) {
};
editCancelBtn?.addEventListener('click', () => {
cancelEditMode({ restoreDraft: true });
if (activeEdit) {
cancelEditMode({ restoreDraft: true });
return;
}
cancelReplyMode({ restoreDraft: true });
});
autoResizeComposer(input);