diff --git a/public/js/chat.js b/public/js/chat.js
index 41b8e57..a859fa8 100644
--- a/public/js/chat.js
+++ b/public/js/chat.js
@@ -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 `
-
- ${esc(who)}
- ${role ? `${esc(role)}` : ''}
-
- ${esc(chatMessageTime(m.created_at))}
-
-
Сообщение удалено
-
`;
- }
+ if (m.deleted_at) return '';
const canDelete = mine || isAdmin();
return `
@@ -144,16 +135,38 @@ function chatMessageHtml(m) {
${m.edited_at ? `изменено` : ''}
${esc(chatMessageTime(m.created_at))}
- ${canDelete ? `
+
+
${mine ? `` : ''}
-
- ` : ''}
+ ${canDelete ? `` : ''}
+
+ ${chatReplyHtml(m)}
${m.body ? `
${esc(m.body)}
` : ''}
${chatFilesHtml(m.files)}
`;
}
+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
+ ? `
})
`
+ : '';
+ const fileLine = !gone && file
+ ? `
${esc(file)}`
+ : '';
+ const head = `
${esc(who)}`;
+ const inner = gone
+ ? `${head}
Сообщение удалено`
+ : `${head}${body ? `
${esc(body)}` : ''}${fileLine}`;
+ if (gone) return `
${inner}
`;
+ return `
`;
+}
+
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 = '
Сообщений пока нет. Напишите первым.
';
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
+ ? `
})
`
+ : `
${esc(fn)}`;
+ 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');
});
diff --git a/public/js/shorts.js b/public/js/shorts.js
index 0b06d7c..77df3fa 100644
--- a/public/js/shorts.js
+++ b/public/js/shorts.js
@@ -157,6 +157,7 @@ function openShortsModal(row) {
document.getElementById('shortsDate').value = row ? row.lesson_date : todayIso();
document.getElementById('shortsTopic').value = row ? (row.topic || '') : '';
document.getElementById('shortsResident').value = row ? (row.resident || '') : '';
+ document.getElementById('shortsResidentField').style.display = row ? '' : 'none';
document.getElementById('shortsUsed').checked = row ? !!row.used : false;
document.getElementById('shortsUsedAt').value = row && row.used_at ? String(row.used_at).slice(0, 10) : '';
const usedDisplay = row ? '' : 'none';
diff --git a/public/shorts.html b/public/shorts.html
index cdf2699..53eab90 100644
--- a/public/shorts.html
+++ b/public/shorts.html
@@ -68,7 +68,7 @@
+
diff --git a/server.js b/server.js
index 6015f06..3794529 100644
--- a/server.js
+++ b/server.js
@@ -463,11 +463,14 @@ async function ensureChatTables() {
body TEXT NOT NULL DEFAULT '',
edited_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
+ reply_to_id INT REFERENCES chat_messages(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT now()
)`);
await pool.query('ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ');
await pool.query('ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ');
+ await pool.query('ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS reply_to_id INT REFERENCES chat_messages(id) ON DELETE SET NULL');
await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_messages_thread ON chat_messages(thread_id, id)`);
+ await pool.query(`CREATE INDEX IF NOT EXISTS idx_chat_messages_reply ON chat_messages(reply_to_id)`);
await pool.query(`CREATE TABLE IF NOT EXISTS chat_attachments (
id SERIAL PRIMARY KEY,
message_id INT NOT NULL REFERENCES chat_messages(id) ON DELETE CASCADE,
@@ -2029,7 +2032,17 @@ async function chatMessageFor(user, threadId, messageId) {
const mid = parseInt(messageId, 10);
if (!Number.isInteger(mid) || mid < 1) return { error: 400, message: 'Invalid message' };
const { rows } = await pool.query(
- `SELECT id, thread_id, sender_id, body, edited_at, deleted_at, created_at
+ `SELECT id, thread_id, sender_id, body, edited_at, deleted_at, reply_to_id, created_at,
+ (SELECT rm.body FROM chat_messages rm WHERE rm.id = chat_messages.reply_to_id) AS reply_body,
+ (SELECT rm.deleted_at FROM chat_messages rm WHERE rm.id = chat_messages.reply_to_id) AS reply_deleted_at,
+ (SELECT a.name FROM chat_attachments a
+ WHERE a.message_id = chat_messages.reply_to_id ORDER BY a.id LIMIT 1) AS reply_file_name,
+ (SELECT a.token FROM chat_attachments a
+ WHERE a.message_id = chat_messages.reply_to_id ORDER BY a.id LIMIT 1) AS reply_file_token,
+ (SELECT ru.name FROM chat_messages rm JOIN users ru ON ru.id = rm.sender_id
+ WHERE rm.id = chat_messages.reply_to_id) AS reply_name,
+ (SELECT ru.username FROM chat_messages rm JOIN users ru ON ru.id = rm.sender_id
+ WHERE rm.id = chat_messages.reply_to_id) AS reply_username
FROM chat_messages WHERE id = $1 AND thread_id = $2`,
[mid, acc.thread.id]
);
@@ -2156,8 +2169,15 @@ app.get('/api/chat/threads/:id/messages', requireAuth, chatLimiter, async (req,
extra = ` AND m.id < $${params.length}`;
}
const { rows } = await pool.query(
- `SELECT m.id, m.sender_id, m.body, m.edited_at, m.deleted_at, m.created_at, u.username, u.name, u.role
- FROM chat_messages m JOIN users u ON u.id = m.sender_id
+ `SELECT m.id, m.sender_id, m.body, m.edited_at, m.deleted_at, m.created_at, u.username, u.name, u.role,
+ m.reply_to_id, rm.body AS reply_body, rm.deleted_at AS reply_deleted_at,
+ ru.name AS reply_name, ru.username AS reply_username,
+ (SELECT a.name FROM chat_attachments a WHERE a.message_id = rm.id ORDER BY a.id LIMIT 1) AS reply_file_name,
+ (SELECT a.token FROM chat_attachments a WHERE a.message_id = rm.id ORDER BY a.id LIMIT 1) AS reply_file_token
+ FROM chat_messages m
+ JOIN users u ON u.id = m.sender_id
+ LEFT JOIN chat_messages rm ON rm.id = m.reply_to_id
+ LEFT JOIN users ru ON ru.id = rm.sender_id
WHERE m.thread_id = $1${extra}
ORDER BY m.id DESC
LIMIT $${params.length + 1}`,
@@ -2188,6 +2208,12 @@ app.get('/api/chat/threads/:id/messages', requireAuth, chatLimiter, async (req,
edited_at: m.edited_at,
deleted_at: m.deleted_at,
created_at: m.created_at,
+ reply_to_id: m.reply_to_id,
+ reply_body: m.reply_deleted_at ? '' : m.reply_body || '',
+ reply_deleted_at: m.reply_deleted_at,
+ reply_sender_name: m.reply_deleted_at ? '' : (m.reply_name || m.reply_username || 'Тьютор'),
+ reply_file_name: m.reply_deleted_at ? null : (m.reply_file_name || null),
+ reply_file_token: m.reply_deleted_at ? null : (m.reply_file_token || null),
files: m.deleted_at ? [] : (byMessage.get(m.id) || []),
}));
const unreadCol = req.user.role === 'admin' ? acc.thread.admin_unread : acc.thread.tutor_unread;
@@ -2284,7 +2310,27 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res,
return res.status(acc.error).json({ error: acc.message });
}
const body = String(req.body?.body || '').trim().slice(0, CHAT_BODY_MAX);
- if (!body && !files.length) return res.status(400).json({ error: 'Пустое сообщение' });
+ if (!body && !files.length) {
+ files.forEach(removeUpload);
+ return res.status(400).json({ error: 'Пустое сообщение' });
+ }
+ let replyToId = null;
+ const rawReply = req.body?.reply_to_id;
+ if (rawReply !== undefined && rawReply !== null && rawReply !== '') {
+ replyToId = parseInt(rawReply, 10);
+ if (!Number.isInteger(replyToId) || replyToId < 1) {
+ files.forEach(removeUpload);
+ return res.status(400).json({ error: 'Некорректное сообщение для ответа' });
+ }
+ const rr = await pool.query(
+ 'SELECT id FROM chat_messages WHERE id = $1 AND thread_id = $2 AND deleted_at IS NULL',
+ [replyToId, acc.thread.id]
+ );
+ if (!rr.rows.length) {
+ files.forEach(removeUpload);
+ return res.status(404).json({ error: 'Сообщение для ответа не найдено' });
+ }
+ }
const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0);
if (totalBytes > MAX_TOTAL_UPLOAD_BYTES) {
files.forEach(removeUpload);
@@ -2297,8 +2343,9 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res,
try {
await client.query('BEGIN');
const mr = await client.query(
- 'INSERT INTO chat_messages (thread_id, sender_id, body) VALUES ($1, $2, $3) RETURNING id, sender_id, body, created_at',
- [acc.thread.id, req.user.id, body]
+ `INSERT INTO chat_messages (thread_id, sender_id, body, reply_to_id)
+ VALUES ($1, $2, $3, $4) RETURNING id, sender_id, body, reply_to_id, created_at`,
+ [acc.thread.id, req.user.id, body, replyToId]
);
created = mr.rows[0];
for (const f of files) {
@@ -2333,6 +2380,26 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res,
'SELECT id, token, name, size FROM chat_attachments WHERE message_id = $1 ORDER BY id',
[created.id]
);
+ let replyBody = '';
+ let replySenderName = '';
+ let replyFileName = null;
+ let replyFileToken = null;
+ if (replyToId) {
+ const { rows: rrows } = await pool.query(
+ `SELECT m.body, m.deleted_at, u.name, u.username,
+ (SELECT a.name FROM chat_attachments a WHERE a.message_id = m.id ORDER BY a.id LIMIT 1) AS file_name,
+ (SELECT a.token FROM chat_attachments a WHERE a.message_id = m.id ORDER BY a.id LIMIT 1) AS file_token
+ FROM chat_messages m JOIN users u ON u.id = m.sender_id
+ WHERE m.id = $1`,
+ [replyToId]
+ );
+ if (rrows.length && !rrows[0].deleted_at) {
+ replyBody = rrows[0].body || '';
+ replySenderName = rrows[0].name || rrows[0].username || 'Тьютор';
+ replyFileName = rrows[0].file_name || null;
+ replyFileToken = rrows[0].file_token || null;
+ }
+ }
const message = {
id: created.id,
sender_id: created.sender_id,
@@ -2343,6 +2410,12 @@ app.post('/api/chat/threads/:id/messages', requireAuth, chatLimiter, (req, res,
edited_at: null,
deleted_at: null,
created_at: created.created_at,
+ reply_to_id: created.reply_to_id,
+ reply_body: replyBody,
+ reply_deleted_at: null,
+ reply_sender_name: replySenderName,
+ reply_file_name: replyFileName,
+ reply_file_token: replyFileToken,
files: frows.map(f => ({ id: f.id, token: f.token, name: f.name, size: f.size })),
};
publishChat({ type: 'message', thread_id: acc.thread.id, tutor_id: acc.thread.tutor_id, message });
@@ -2388,6 +2461,12 @@ app.put('/api/chat/threads/:id/messages/:messageId', requireAuth, chatLimiter, a
edited_at: updated.edited_at,
deleted_at: updated.deleted_at,
created_at: updated.created_at,
+ reply_to_id: updated.reply_to_id,
+ reply_body: message.reply_body || '',
+ reply_deleted_at: message.reply_deleted_at || null,
+ reply_sender_name: message.reply_sender_name || '',
+ reply_file_name: message.reply_file_name || null,
+ reply_file_token: message.reply_file_token || null,
files: frows.map(f => ({ id: f.id, token: f.token, name: f.name, size: f.size })),
};
publishChat({
@@ -3812,15 +3891,18 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
[x.id, x.tutor_id, x.last_message_at, x.last_message_text, x.tutor_unread || 0, x.admin_unread || 0, x.deleted_at || null, x.purge_at || null, x.created_at]
);
}
+ const restoredMsgIds = new Set();
for (const x of ndata.chat_messages) {
const ex = await client.query('SELECT 1 FROM chat_threads WHERE id = $1', [x.thread_id]);
if (!ex.rowCount) continue;
const eu = await client.query('SELECT 1 FROM users WHERE id = $1', [x.sender_id]);
if (!eu.rowCount) continue;
+ const replyTo = x.reply_to_id && restoredMsgIds.has(x.reply_to_id) ? x.reply_to_id : null;
await client.query(
- 'INSERT INTO chat_messages (id, thread_id, sender_id, body, edited_at, deleted_at, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7)',
- [x.id, x.thread_id, x.sender_id, x.body, x.edited_at, x.deleted_at, x.created_at]
+ 'INSERT INTO chat_messages (id, thread_id, sender_id, body, edited_at, deleted_at, reply_to_id, created_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)',
+ [x.id, x.thread_id, x.sender_id, x.body, x.edited_at, x.deleted_at, replyTo, x.created_at]
);
+ restoredMsgIds.add(x.id);
}
for (const x of ndata.chat_attachments) {
const ex = await client.query('SELECT 1 FROM chat_messages WHERE id = $1', [x.message_id]);