feat(chat): ответы на сообщения с цитатой и переходом к оригиналу

Кнопка «Ответить» у каждого сообщения: цитата показывается в новом
сообщении и в поле ввода, отмена — ✕ или Escape, переключение треда
сбрасывает ответ. Клик по цитате прокручивает ленту к исходному
сообщению и подсвечивает его.

Цитата повторяет вложение оригинала: имя файла с иконкой, для
картинок — миниатюра WebP через существующий /api/chat/files/:token.

- chat_messages.reply_to_id: FK на саму таблицу с ON DELETE SET NULL,
  миграция и ensureChatTables, индекс
- POST .../messages принимает reply_to_id и проверяет, что родитель
  в этом же треде и не удалён; вложения при ошибке подчищаются
- цитата едет в списке, при отправке и в SSE правки/удаления, так что
  у собеседника она обновляется по живому каналу
- бэкап: reply_to_id в normalizeRestoreData, при restore ссылка
  принимается только если родитель восстановился, иначе NULL
- shorts: скрывать поле «Резидент» при добавлении без разбора строк
This commit is contained in:
dev
2026-10-06 00:33:17 +03:00
parent 85b737c0aa
commit 3ac54f57b4
9 changed files with 258 additions and 26 deletions
+127 -16
View File
@@ -17,6 +17,7 @@ let chatPendingFiles = [];
let chatLoading = false;
let chatSending = false;
let chatEditId = 0;
let chatReplyTo = null;
let chatHiddenMode = false;
let chatHiddenCount = 0;
@@ -125,17 +126,7 @@ function chatMessageHtml(m) {
const mine = Number(m.sender_id) === Number(currentUser?.id);
const who = mine ? 'Вы' : (m.sender_name || 'Тьютор');
const role = mine ? '' : (m.sender_role === 'admin' ? 'администратор' : 'тьютор');
if (m.deleted_at) {
return `<div class="msg msg-deleted ${mine ? 'msg-mine' : ''}" data-msg="${m.id}">
<div class="msg-head">
<span class="msg-who">${esc(who)}</span>
${role ? `<span class="msg-role">${esc(role)}</span>` : ''}
<span class="grow"></span>
<span class="msg-time">${esc(chatMessageTime(m.created_at))}</span>
</div>
<div class="msg-body msg-deleted-body">Сообщение удалено</div>
</div>`;
}
if (m.deleted_at) return '';
const canDelete = mine || isAdmin();
return `<div class="msg ${mine ? 'msg-mine' : ''}" data-msg="${m.id}">
<div class="msg-head">
@@ -144,16 +135,38 @@ function chatMessageHtml(m) {
${m.edited_at ? `<span class="msg-edited" title="Изменено ${esc(chatMessageTime(m.edited_at))}">изменено</span>` : ''}
<span class="grow"></span>
<span class="msg-time">${esc(chatMessageTime(m.created_at))}</span>
${canDelete ? `<span class="msg-actions">
<span class="msg-actions">
<button type="button" class="msg-act" data-msg-reply="${m.id}" title="Ответить"><i data-lucide="corner-up-left"></i></button>
${mine ? `<button type="button" class="msg-act" data-msg-edit="${m.id}" title="Редактировать"><i data-lucide="pencil"></i></button>` : ''}
<button type="button" class="msg-act msg-act-del" data-msg-del="${m.id}" title="Удалить"><i data-lucide="trash-2"></i></button>
</span>` : ''}
${canDelete ? `<button type="button" class="msg-act msg-act-del" data-msg-del="${m.id}" title="Удалить"><i data-lucide="trash-2"></i></button>` : ''}
</span>
</div>
${chatReplyHtml(m)}
${m.body ? `<div class="msg-body">${esc(m.body)}</div>` : ''}
${chatFilesHtml(m.files)}
</div>`;
}
function chatReplyHtml(m) {
if (!m.reply_to_id) return '';
const who = m.reply_sender_name || 'Тьютор';
const gone = !!m.reply_deleted_at;
const body = String(m.reply_body || '').trim();
const file = m.reply_file_name ? String(m.reply_file_name) : '';
const img = !gone && file && CHAT_IMAGE_EXT.test(file) && m.reply_file_token
? `<img class="msg-reply-img" src="${chatFileThumbUrl({ token: m.reply_file_token })}" alt="" loading="lazy">`
: '';
const fileLine = !gone && file
? `<span class="msg-reply-file"><i data-lucide="${chatFileIcon(file)}"></i><span title="${esc(file)}">${esc(file)}</span></span>`
: '';
const head = `<span class="msg-reply-who">${esc(who)}</span>`;
const inner = gone
? `${head}<span class="msg-reply-text">Сообщение удалено</span>`
: `${head}${body ? `<span class="msg-reply-text">${esc(body)}</span>` : ''}${fileLine}`;
if (gone) return `<div class="msg-reply">${inner}</div>`;
return `<button type="button" class="msg-reply" data-msg-jump="${m.reply_to_id}" title="Перейти к сообщению">${img}${inner}</button>`;
}
function addChatMessage(m) {
if (!m || m.id === undefined || m.id === null) return false;
if (chatMessages.some(x => Number(x.id) === Number(m.id))) return false;
@@ -161,14 +174,19 @@ function addChatMessage(m) {
return true;
}
function chatVisibleMessages() {
return chatMessages.filter(m => !m.deleted_at);
}
function renderChatMessages() {
const box = document.getElementById('chatMessages');
if (!box) return;
if (!chatMessages.length) {
const visible = chatVisibleMessages();
if (!visible.length) {
box.innerHTML = '<div class="chat-msgs-empty">Сообщений пока нет. Напишите первым.</div>';
return;
}
box.innerHTML = chatMessages.map(chatMessageHtml).join('');
box.innerHTML = visible.map(chatMessageHtml).join('');
renderIcons();
}
@@ -177,6 +195,15 @@ function scrollChatDown() {
if (box) box.scrollTop = box.scrollHeight;
}
function scrollChatToMessage(messageId) {
const box = document.getElementById('chatMessages');
const node = box && box.querySelector(`.msg[data-msg="${messageId}"]`);
if (!node) return;
node.scrollIntoView({ block: 'center', behavior: 'smooth' });
node.classList.add('msg-flash');
setTimeout(() => node.classList.remove('msg-flash'), 1400);
}
function chatAtBottom() {
const box = document.getElementById('chatMessages');
if (!box) return true;
@@ -416,8 +443,59 @@ function autoGrowChatInput(el) {
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
}
function renderChatReplyBar() {
const bar = document.getElementById('chatReplyBar');
if (!bar) return;
if (!chatReplyTo) {
bar.hidden = true;
return;
}
const who = document.getElementById('chatReplyWho');
const text = document.getElementById('chatReplyText');
const file = document.getElementById('chatReplyFile');
if (who) who.textContent = chatReplyTo.sender_name || 'Тьютор';
if (text) {
const body = String(chatReplyTo.body || '').trim();
text.textContent = body || (chatReplyTo.file_name ? '' : 'Вложение');
text.hidden = !text.textContent;
}
if (file) {
const fn = chatReplyTo.file_name;
file.innerHTML = !fn
? ''
: CHAT_IMAGE_EXT.test(fn) && chatReplyTo.file_token
? `<img class="chat-reply-img" src="${chatFileThumbUrl({ token: chatReplyTo.file_token })}" alt="">`
: `<i data-lucide="${chatFileIcon(fn)}"></i><span title="${esc(fn)}">${esc(fn)}</span>`;
file.hidden = !fn;
}
bar.hidden = false;
renderIcons();
}
function setChatReply(messageId) {
const msg = chatMessages.find(m => Number(m.id) === Number(messageId));
if (!msg || msg.deleted_at) return;
const files = msg.files || [];
chatReplyTo = {
id: Number(msg.id),
sender_name: msg.sender_name,
body: msg.body,
file_name: files.length ? files[0].name : '',
file_token: files.length ? files[0].token : '',
};
renderChatReplyBar();
const input = document.getElementById('chatInput');
if (input) input.focus();
}
function clearChatReply() {
chatReplyTo = null;
renderChatReplyBar();
}
function selectChatThread(id) {
chatActiveId = Number(id) || 0;
clearChatReply();
renderChatThreads();
loadChatMessages();
}
@@ -464,6 +542,14 @@ function onChatMessageUpdate(p) {
} else {
chatMessages[idx] = { ...chatMessages[idx], ...m };
}
for (const x of chatMessages) {
if (Number(x.reply_to_id) === Number(m.id)) {
x.reply_body = m.body || '';
x.reply_sender_name = m.sender_name || x.reply_sender_name;
x.reply_file_name = m.files && m.files.length ? m.files[0].name : null;
x.reply_file_token = m.files && m.files.length ? m.files[0].token : null;
}
}
renderChatMessages();
}
@@ -474,6 +560,15 @@ function onChatMessageDelete(p) {
const m = p.message;
const idx = chatMessages.findIndex(x => Number(x.id) === Number(m.id));
if (idx >= 0) chatMessages[idx] = { ...chatMessages[idx], ...m, body: '', files: [], edited_at: null };
for (const x of chatMessages) {
if (Number(x.reply_to_id) === Number(m.id)) {
x.reply_body = '';
x.reply_file_name = null;
x.reply_file_token = null;
x.reply_deleted_at = m.deleted_at || new Date().toISOString();
}
}
if (chatReplyTo && Number(chatReplyTo.id) === Number(m.id)) clearChatReply();
renderChatMessages();
if (Number(m.sender_id) !== Number(currentUser?.id)) markChatRead();
}
@@ -552,6 +647,7 @@ async function deleteChatMessage(messageId) {
if (idx >= 0) {
chatMessages[idx] = { ...chatMessages[idx], body: '', files: [], edited_at: null, deleted_at: new Date().toISOString() };
}
if (chatReplyTo && Number(chatReplyTo.id) === Number(messageId)) clearChatReply();
renderChatMessages();
loadChatThreads();
} catch (e) {
@@ -590,10 +686,12 @@ async function sendChatMessage() {
try {
const fd = new FormData();
fd.append('body', body);
if (chatReplyTo) fd.append('reply_to_id', String(chatReplyTo.id));
for (const f of files) fd.append('files', f, f.name);
const data = await chatApi(`/api/chat/threads/${chatActiveId}/messages`, { method: 'POST', headers: hdr(), body: fd });
chatPendingFiles = [];
renderChatAttach();
clearChatReply();
input.value = '';
autoGrowChatInput();
const stick = chatAtBottom();
@@ -672,6 +770,16 @@ function initChatPage() {
if (box) {
box.addEventListener('click', e => {
const jumpBtn = e.target.closest('[data-msg-jump]');
if (jumpBtn) {
scrollChatToMessage(Number(jumpBtn.dataset.msgJump));
return;
}
const replyBtn = e.target.closest('[data-msg-reply]');
if (replyBtn) {
setChatReply(Number(replyBtn.dataset.msgReply));
return;
}
const editBtn = e.target.closest('[data-msg-edit]');
if (editBtn) {
openChatEditModal(Number(editBtn.dataset.msgEdit));
@@ -681,6 +789,8 @@ function initChatPage() {
if (delBtn) deleteChatMessage(Number(delBtn.dataset.msgDel));
});
}
const replyCancel = document.getElementById('chatReplyCancel');
if (replyCancel) replyCancel.addEventListener('click', clearChatReply);
if (editCancel) editCancel.addEventListener('click', closeChatEditModal);
if (editSave) editSave.addEventListener('click', submitChatEdit);
if (editModal) {
@@ -780,6 +890,7 @@ function initChatPage() {
document.addEventListener('keydown', e => {
if (e.key !== 'Escape') return;
if (editModal && editModal.classList.contains('open')) { closeChatEditModal(); return; }
if (chatReplyTo) { clearChatReply(); return; }
if (imgModal && imgModal.classList.contains('open')) { imgModal.classList.remove('open'); return; }
if (newModal && newModal.classList.contains('open')) newModal.classList.remove('open');
});