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:
+127
-16
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user