Кнопка «Ответить» у каждого сообщения: цитата показывается в новом сообщении и в поле ввода, отмена — ✕ или 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: скрывать поле «Резидент» при добавлении без разбора строк
935 lines
35 KiB
JavaScript
935 lines
35 KiB
JavaScript
const CHAT_PAGE_SIZE = 100;
|
||
const CHAT_MAX_FILES = 10;
|
||
const CHAT_MAX_FILE_MB = 50;
|
||
const CHAT_ALLOWED_EXT = new Set([
|
||
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.heic', '.heif', '.jfif',
|
||
'.pdf', '.doc', '.docx', '.txt', '.md', '.rtf', '.odt', '.ods',
|
||
'.xls', '.xlsx', '.csv', '.ppt', '.pptx',
|
||
'.zip', '.rar', '.7z', '.tar', '.gz',
|
||
]);
|
||
const CHAT_IMAGE_EXT = /\.(jpe?g|jfif|png|gif|webp|bmp|avif|ico)$/i;
|
||
const CHAT_VIDEO_EXT = /\.(?:mp4|m4v|webm|ogv)$/i;
|
||
|
||
let chatThreads = [];
|
||
let chatActiveId = 0;
|
||
let chatMessages = [];
|
||
let chatPendingFiles = [];
|
||
let chatLoading = false;
|
||
let chatSending = false;
|
||
let chatEditId = 0;
|
||
let chatReplyTo = null;
|
||
let chatHiddenMode = false;
|
||
let chatHiddenCount = 0;
|
||
|
||
function chatHumanSize(b) {
|
||
const n = Number(b) || 0;
|
||
if (n < 1024) return `${n} Б`;
|
||
if (n < 1048576) return `${(n / 1024).toFixed(1)} КБ`;
|
||
return `${(n / 1048576).toFixed(1)} МБ`;
|
||
}
|
||
|
||
function chatExt(name) {
|
||
const m = /\.[^.\\/]+$/.exec(String(name || ''));
|
||
return m ? m[0].toLowerCase() : '';
|
||
}
|
||
|
||
function chatFileIcon(name) {
|
||
if (CHAT_IMAGE_EXT.test(name || '')) return 'image';
|
||
if (CHAT_VIDEO_EXT.test(name || '')) return 'video';
|
||
if (/\.(zip|rar|7z)$/i.test(name || '')) return 'archive';
|
||
if (/\.pdf$/i.test(name || '')) return 'file-text';
|
||
if (/\.(xlsx?|csv)$/i.test(name || '')) return 'sheet';
|
||
if (/\.(docx?|rtf|txt|md)$/i.test(name || '')) return 'file-type';
|
||
return 'paperclip';
|
||
}
|
||
|
||
function chatFileUrl(f, play) {
|
||
const base = `${API}/api/chat/files/${f.token}?token=${encodeURIComponent(token)}`;
|
||
if (play && CHAT_VIDEO_EXT.test(f.name || '')) return `${base}&play=1`;
|
||
return base;
|
||
}
|
||
|
||
function chatFileThumbUrl(f) {
|
||
return `${chatFileUrl(f)}&thumb=1`;
|
||
}
|
||
|
||
function chatThreadTime(ts) {
|
||
if (!ts) return '';
|
||
const d = new Date(ts);
|
||
if (Number.isNaN(d.getTime())) return '';
|
||
const diff = Date.now() - d.getTime();
|
||
if (diff < 60000) return 'только что';
|
||
if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`;
|
||
if (diff < 86400000) return fmtTimeOnly(d);
|
||
return fmtDateShort(d);
|
||
}
|
||
|
||
function chatThreadCardHtml(t) {
|
||
const active = t.id === chatActiveId ? ' active' : '';
|
||
const unread = Number(t.unread) || 0;
|
||
const preview = (t.last_message_text || '').trim() || 'Нет сообщений';
|
||
const hidden = t.deleted_at ? ' is-hidden' : '';
|
||
const acts = isAdmin() ? (t.deleted_at
|
||
? `<span class="chat-thread-acts">
|
||
<button type="button" class="chat-thread-act" data-thread-restore="${t.id}" title="Вернуть диалог в список"><i data-lucide="archive-restore"></i></button>
|
||
<button type="button" class="chat-thread-act chat-thread-act-danger" data-thread-purge="${t.id}" title="Удалить диалог безвозвратно"><i data-lucide="trash-2"></i></button>
|
||
</span>`
|
||
: `<span class="chat-thread-acts">
|
||
<button type="button" class="chat-thread-act" data-thread-hide="${t.id}" title="Скрыть диалог"><i data-lucide="archive"></i></button>
|
||
</span>`) : '';
|
||
return `<div class="chat-thread${active}${hidden}${unread ? ' unread' : ''}" data-thread="${t.id}" role="button" tabindex="0">
|
||
<span class="chat-thread-avatar">${esc((t.tutor_name || '?').slice(0, 1).toUpperCase())}</span>
|
||
<span class="chat-thread-body">
|
||
<span class="chat-thread-top">
|
||
<span class="chat-thread-name">${esc(t.tutor_name || t.tutor_username)}</span>
|
||
${t.tutor_active === false ? '<span class="chat-off" title="Пользователь отключён">отключён</span>' : ''}
|
||
${t.deleted_at ? '<span class="chat-off chat-hidden-tag" title="Диалог скрыт">скрыт</span>' : ''}
|
||
<span class="grow"></span>
|
||
<span class="chat-thread-time">${esc(chatThreadTime(t.last_message_at || t.created_at))}</span>
|
||
</span>
|
||
<span class="chat-thread-preview">${esc(preview)}</span>
|
||
</span>
|
||
${unread ? `<span class="chat-thread-badge">${unread > 99 ? '99+' : unread}</span>` : ''}
|
||
${acts}
|
||
</div>`;
|
||
}
|
||
|
||
function renderChatThreads() {
|
||
const list = document.getElementById('chatThreadList');
|
||
if (!list) return;
|
||
if (!chatThreads.length) {
|
||
list.innerHTML = `<div class="chat-side-empty">${chatHiddenMode ? 'Скрытых диалогов нет' : 'Диалогов пока нет'}</div>`;
|
||
return;
|
||
}
|
||
list.innerHTML = chatThreads.map(chatThreadCardHtml).join('');
|
||
renderIcons();
|
||
}
|
||
|
||
function chatFilesHtml(files) {
|
||
if (!files || !files.length) return '';
|
||
return `<div class="msg-files">${files.map(f => {
|
||
if (CHAT_IMAGE_EXT.test(f.name || '')) {
|
||
return `<a class="msg-file msg-file-img" href="${chatFileUrl(f)}" data-img="${esc(chatFileUrl(f))}" title="Открыть">
|
||
<img src="${chatFileThumbUrl(f)}" alt="${esc(f.name)}" loading="lazy">
|
||
<span class="msg-file-name">${esc(f.name)}</span>
|
||
</a>`;
|
||
}
|
||
return `<a class="msg-file" href="${chatFileUrl(f, true)}" target="_blank" rel="noopener">
|
||
<i data-lucide="${chatFileIcon(f.name)}"></i>
|
||
<span class="msg-file-name">${esc(f.name)}</span>
|
||
<span class="msg-file-size">${chatHumanSize(f.size)}</span>
|
||
</a>`;
|
||
}).join('')}</div>`;
|
||
}
|
||
|
||
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 '';
|
||
const canDelete = mine || isAdmin();
|
||
return `<div class="msg ${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>` : ''}
|
||
${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>
|
||
<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>` : ''}
|
||
${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;
|
||
chatMessages.push(m);
|
||
return true;
|
||
}
|
||
|
||
function chatVisibleMessages() {
|
||
return chatMessages.filter(m => !m.deleted_at);
|
||
}
|
||
|
||
function renderChatMessages() {
|
||
const box = document.getElementById('chatMessages');
|
||
if (!box) return;
|
||
const visible = chatVisibleMessages();
|
||
if (!visible.length) {
|
||
box.innerHTML = '<div class="chat-msgs-empty">Сообщений пока нет. Напишите первым.</div>';
|
||
return;
|
||
}
|
||
box.innerHTML = visible.map(chatMessageHtml).join('');
|
||
renderIcons();
|
||
}
|
||
|
||
function scrollChatDown() {
|
||
const box = document.getElementById('chatMessages');
|
||
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;
|
||
return box.scrollHeight - box.scrollTop - box.clientHeight < 120;
|
||
}
|
||
|
||
function chatMessageTime(ts) {
|
||
const d = new Date(ts);
|
||
if (Number.isNaN(d.getTime())) return '';
|
||
return `${fmtDateShort(d)} ${fmtTimeOnly(d)}`;
|
||
}
|
||
async function chatApi(path, options) {
|
||
const res = await fetch(`${API}${path}`, options);
|
||
let data = null;
|
||
try { data = await res.json(); } catch {}
|
||
if (!res.ok) throw new Error((data && data.error) || `Ошибка ${res.status}`);
|
||
return data;
|
||
}
|
||
|
||
async function loadChatThreads() {
|
||
try {
|
||
const data = await chatApi(`/api/chat/threads${chatHiddenMode ? '?deleted=1' : ''}`, { headers: hdr() });
|
||
chatThreads = data.items || [];
|
||
chatHiddenCount = Number(data.hidden) || 0;
|
||
setChatUnread(data.unread);
|
||
renderChatHiddenBtn();
|
||
renderChatThreads();
|
||
} catch {
|
||
showToast('Не удалось загрузить диалоги', true);
|
||
}
|
||
}
|
||
|
||
function renderChatHiddenBtn() {
|
||
const btn = document.getElementById('chatHiddenBtn');
|
||
const hint = document.getElementById('chatHiddenHint');
|
||
const count = document.getElementById('chatHiddenCount');
|
||
if (!btn) return;
|
||
btn.hidden = !isAdmin();
|
||
btn.classList.toggle('on', chatHiddenMode);
|
||
if (hint) hint.hidden = !chatHiddenMode;
|
||
if (count) {
|
||
count.hidden = !chatHiddenMode || !chatHiddenCount;
|
||
count.textContent = chatHiddenCount > 99 ? '99+' : String(chatHiddenCount);
|
||
}
|
||
btn.title = chatHiddenMode ? 'Показать активные диалоги' : 'Показать скрытые диалоги';
|
||
}
|
||
|
||
function resetChatHiddenMode() {
|
||
if (!chatHiddenMode) return;
|
||
chatHiddenMode = false;
|
||
chatActiveId = 0;
|
||
renderChatHiddenBtn();
|
||
}
|
||
|
||
function afterChatThreadListChanged() {
|
||
const stillThere = chatThreads.some(t => t.id === chatActiveId);
|
||
if (!stillThere) chatActiveId = 0;
|
||
renderChatThreads();
|
||
if (chatActiveId) loadChatMessages();
|
||
else {
|
||
renderChatHeader();
|
||
renderChatMessages();
|
||
}
|
||
}
|
||
|
||
async function hideChatThread(id) {
|
||
const thread = chatThreads.find(t => t.id === Number(id));
|
||
if (!thread) return;
|
||
const name = thread.tutor_name || thread.tutor_username || 'диалог';
|
||
if (!confirm(`Скрыть диалог с «${name}»? Он уйдёт из списка активных, сообщения сохранятся и будут удалены автоматически через срок из настроек корзины.`)) return;
|
||
try {
|
||
await chatApi(`/api/chat/threads/${id}`, { method: 'DELETE', headers: hdr() });
|
||
chatHiddenCount += 1;
|
||
showToast('Диалог скрыт');
|
||
if (chatActiveId === Number(id)) chatActiveId = 0;
|
||
chatThreads = chatThreads.filter(t => t.id !== Number(id));
|
||
renderChatHiddenBtn();
|
||
afterChatThreadListChanged();
|
||
loadChatUnread();
|
||
} catch (e) {
|
||
showToast(e.message || 'Не удалось скрыть диалог', true);
|
||
}
|
||
}
|
||
|
||
async function restoreChatThread(id) {
|
||
try {
|
||
await chatApi(`/api/chat/threads/${id}/restore`, { method: 'PUT', headers: hdr() });
|
||
chatHiddenCount = Math.max(0, chatHiddenCount - 1);
|
||
showToast('Диалог восстановлен');
|
||
if (chatActiveId === Number(id)) chatActiveId = 0;
|
||
chatThreads = chatThreads.filter(t => t.id !== Number(id));
|
||
renderChatHiddenBtn();
|
||
afterChatThreadListChanged();
|
||
loadChatUnread();
|
||
} catch (e) {
|
||
showToast(e.message || 'Не удалось восстановить диалог', true);
|
||
}
|
||
}
|
||
|
||
async function purgeChatThread(id) {
|
||
const thread = chatThreads.find(t => t.id === Number(id));
|
||
if (!thread) return;
|
||
const name = thread.tutor_name || thread.tutor_username || 'диалог';
|
||
if (!confirm(`Удалить диалог с «${name}» безвозвратно? Сообщения и вложения будут удалены после срока из настроек корзины.`)) return;
|
||
try {
|
||
await chatApi(`/api/chat/threads/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||
chatThreads = chatThreads.filter(t => t.id !== Number(id));
|
||
if (chatActiveId === Number(id)) chatActiveId = 0;
|
||
afterChatThreadListChanged();
|
||
showToast('Удаление запланировано');
|
||
} catch (e) {
|
||
showToast(e.message || 'Не удалось запланировать удаление', true);
|
||
}
|
||
}
|
||
|
||
function renderChatHeader() {
|
||
const thread = chatThreads.find(t => t.id === chatActiveId);
|
||
const empty = document.getElementById('chatEmpty');
|
||
const main = document.getElementById('chatMain');
|
||
if (!thread) {
|
||
if (empty) empty.hidden = false;
|
||
if (main) main.hidden = true;
|
||
return;
|
||
}
|
||
if (empty) empty.hidden = true;
|
||
if (main) main.hidden = false;
|
||
const av = document.getElementById('chatAvatar');
|
||
const nm = document.getElementById('chatPeerName');
|
||
const sub = document.getElementById('chatPeerSub');
|
||
if (av) av.textContent = (thread.tutor_name || '?').slice(0, 1).toUpperCase();
|
||
if (nm) nm.textContent = isAdmin() ? (thread.tutor_name || thread.tutor_username) : 'Администратор';
|
||
if (sub) {
|
||
sub.textContent = isAdmin()
|
||
? `@${thread.tutor_username}${thread.tutor_active === false ? ' · отключён' : ''}`
|
||
: 'Ответ администратора';
|
||
}
|
||
}
|
||
|
||
async function loadChatMessages() {
|
||
if (!chatActiveId) {
|
||
renderChatHeader();
|
||
renderChatMessages();
|
||
return;
|
||
}
|
||
if (chatLoading) return;
|
||
chatLoading = true;
|
||
try {
|
||
const data = await chatApi(`/api/chat/threads/${chatActiveId}/messages?limit=${CHAT_PAGE_SIZE}`, { headers: hdr() });
|
||
chatMessages = data.items || [];
|
||
if (data.thread) {
|
||
const idx = chatThreads.findIndex(t => t.id === data.thread.id);
|
||
if (idx >= 0) chatThreads[idx] = data.thread;
|
||
}
|
||
renderChatHeader();
|
||
renderChatThreads();
|
||
renderChatMessages();
|
||
scrollChatDown();
|
||
await markChatRead();
|
||
} catch (e) {
|
||
showToast(e.message || 'Не удалось загрузить сообщения', true);
|
||
} finally {
|
||
chatLoading = false;
|
||
}
|
||
}
|
||
|
||
async function markChatRead() {
|
||
if (!chatActiveId) return;
|
||
try {
|
||
const data = await chatApi(`/api/chat/threads/${chatActiveId}/read`, { method: 'POST', headers: hdr() });
|
||
setChatUnread(data.unread);
|
||
const thread = chatThreads.find(t => t.id === chatActiveId);
|
||
if (thread) thread.unread = 0;
|
||
renderChatThreads();
|
||
} catch {}
|
||
}
|
||
|
||
function chatPasteFileName(file) {
|
||
const base = String(file.name || '').trim();
|
||
const stem = base.replace(/\.[a-z0-9]{1,8}$/i, '');
|
||
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
|
||
const pad = n => String(n).padStart(2, '0');
|
||
const d = new Date();
|
||
return `Вставка ${todayIso()} ${nowHm().replace(':', '-')}-${pad(d.getSeconds())}${chatExt(file.name) || '.png'}`;
|
||
}
|
||
|
||
function renderChatAttach() {
|
||
const box = document.getElementById('chatAttachList');
|
||
if (!box) return;
|
||
if (!chatPendingFiles.length) {
|
||
box.hidden = true;
|
||
box.innerHTML = '';
|
||
return;
|
||
}
|
||
box.hidden = false;
|
||
box.innerHTML = chatPendingFiles.map((f, i) => `<span class="chat-attach-chip">
|
||
<i data-lucide="${chatFileIcon(f.name)}"></i>
|
||
<span class="chat-attach-name" title="${esc(f.name)}">${esc(f.name)}</span>
|
||
<span class="chat-attach-size">${chatHumanSize(f.size)}</span>
|
||
<button type="button" class="chat-attach-del" data-remove="${i}" title="Убрать">✕</button>
|
||
</span>`).join('');
|
||
renderIcons();
|
||
}
|
||
|
||
function addChatFiles(list) {
|
||
const skipped = [];
|
||
let added = 0;
|
||
for (const file of list) {
|
||
if (chatPendingFiles.length >= CHAT_MAX_FILES) {
|
||
skipped.push(`Не больше ${CHAT_MAX_FILES} файлов`);
|
||
break;
|
||
}
|
||
if (file.size > CHAT_MAX_FILE_MB * 1024 * 1024) {
|
||
skipped.push(`«${file.name}» больше ${CHAT_MAX_FILE_MB} МБ`);
|
||
continue;
|
||
}
|
||
if (!CHAT_ALLOWED_EXT.has(chatExt(file.name))) {
|
||
skipped.push(`«${file.name}»: недопустимый тип файла`);
|
||
continue;
|
||
}
|
||
chatPendingFiles.push(file);
|
||
added++;
|
||
}
|
||
if (added) renderChatAttach();
|
||
if (skipped.length) showToast(skipped[0], true);
|
||
return added;
|
||
}
|
||
|
||
function removeChatFile(i) {
|
||
chatPendingFiles.splice(i, 1);
|
||
renderChatAttach();
|
||
}
|
||
|
||
function autoGrowChatInput(el) {
|
||
const input = el || document.getElementById('chatInput');
|
||
if (!input) return;
|
||
input.style.height = 'auto';
|
||
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();
|
||
}
|
||
|
||
function onChatMessage(p) {
|
||
if (!p || !p.message) return;
|
||
const threadId = Number(p.thread_id);
|
||
const idx = chatThreads.findIndex(t => t.id === threadId);
|
||
const m = p.message;
|
||
if (idx < 0) {
|
||
loadChatThreads();
|
||
} else if (chatHiddenMode) {
|
||
renderChatThreads();
|
||
} else {
|
||
chatThreads[idx].last_message_text = m.body || 'Вложение';
|
||
chatThreads[idx].last_message_at = m.created_at;
|
||
renderChatThreads();
|
||
}
|
||
if (threadId !== chatActiveId) return;
|
||
const stick = chatAtBottom();
|
||
addChatMessage(m);
|
||
renderChatMessages();
|
||
if (stick) scrollChatDown();
|
||
markChatRead();
|
||
}
|
||
|
||
function applyChatThreadPreview(p) {
|
||
if (!p || !p.preview) return;
|
||
const idx = chatThreads.findIndex(t => t.id === Number(p.thread_id));
|
||
if (idx < 0) return;
|
||
chatThreads[idx].last_message_text = p.preview.last_message_text || '';
|
||
chatThreads[idx].last_message_at = p.preview.last_message_at || chatThreads[idx].last_message_at;
|
||
renderChatThreads();
|
||
}
|
||
|
||
function onChatMessageUpdate(p) {
|
||
if (!p || !p.message) return;
|
||
applyChatThreadPreview(p);
|
||
if (Number(p.thread_id) !== chatActiveId) return;
|
||
const m = p.message;
|
||
const idx = chatMessages.findIndex(x => Number(x.id) === Number(m.id));
|
||
if (idx < 0) {
|
||
addChatMessage(m);
|
||
} 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();
|
||
}
|
||
|
||
function onChatMessageDelete(p) {
|
||
if (!p || !p.message) return;
|
||
applyChatThreadPreview(p);
|
||
if (Number(p.thread_id) !== chatActiveId) return;
|
||
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();
|
||
}
|
||
|
||
function onChatThreadUpdate(p) {
|
||
if (!p || !isAdmin()) return;
|
||
if (p.deleted_at) {
|
||
if (chatHiddenMode) return;
|
||
chatHiddenCount += 1;
|
||
} else {
|
||
if (!chatHiddenMode) return;
|
||
chatHiddenCount = Math.max(0, chatHiddenCount - 1);
|
||
}
|
||
loadChatThreads();
|
||
}
|
||
|
||
function openChatEditModal(messageId) {
|
||
const modal = document.getElementById('chatEditModal');
|
||
const input = document.getElementById('chatEditText');
|
||
if (!modal || !input) return;
|
||
const msg = chatMessages.find(m => Number(m.id) === Number(messageId));
|
||
if (!msg || msg.deleted_at) return;
|
||
chatEditId = Number(messageId);
|
||
input.value = String(msg.body || '');
|
||
modal.classList.add('open');
|
||
input.focus();
|
||
input.setSelectionRange(input.value.length, input.value.length);
|
||
autoGrowChatInput(input);
|
||
}
|
||
|
||
function closeChatEditModal() {
|
||
const modal = document.getElementById('chatEditModal');
|
||
if (modal) modal.classList.remove('open');
|
||
chatEditId = 0;
|
||
}
|
||
|
||
async function submitChatEdit() {
|
||
const input = document.getElementById('chatEditText');
|
||
const saveBtn = document.getElementById('chatEditSave');
|
||
if (!input || !chatEditId) return;
|
||
const body = String(input.value || '').trim();
|
||
if (!body) {
|
||
showToast('Сообщение не может быть пустым', true);
|
||
return;
|
||
}
|
||
if (saveBtn) saveBtn.disabled = true;
|
||
try {
|
||
const data = await chatApi(`/api/chat/threads/${chatActiveId}/messages/${chatEditId}`, {
|
||
method: 'PUT',
|
||
headers: hdrJson(),
|
||
body: JSON.stringify({ body }),
|
||
});
|
||
if (data.message) {
|
||
const idx = chatMessages.findIndex(m => Number(m.id) === Number(data.message.id));
|
||
if (idx >= 0) chatMessages[idx] = { ...chatMessages[idx], ...data.message };
|
||
renderChatMessages();
|
||
}
|
||
closeChatEditModal();
|
||
} catch (e) {
|
||
showToast(e.message || 'Не удалось изменить сообщение', true);
|
||
} finally {
|
||
if (saveBtn) saveBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function deleteChatMessage(messageId) {
|
||
const msg = chatMessages.find(m => Number(m.id) === Number(messageId));
|
||
if (!msg) return;
|
||
const mine = Number(msg.sender_id) === Number(currentUser?.id);
|
||
if (!confirm(mine
|
||
? 'Удалить сообщение? Вложения будут удалены безвозвратно.'
|
||
: 'Удалить сообщение собеседника? Вложения будут удалены безвозвратно.')) return;
|
||
try {
|
||
await chatApi(`/api/chat/threads/${chatActiveId}/messages/${messageId}`, { method: 'DELETE', headers: hdr() });
|
||
const idx = chatMessages.findIndex(m => Number(m.id) === Number(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) {
|
||
showToast(e.message || 'Не удалось удалить сообщение', true);
|
||
}
|
||
}
|
||
|
||
async function openChatNewModal() {
|
||
const modal = document.getElementById('chatNewModal');
|
||
const select = document.getElementById('chatNewUser');
|
||
if (!modal || !select) return;
|
||
try {
|
||
const users = await chatApi('/api/users', { headers: hdr() });
|
||
const tutors = (users || []).filter(u => u.role !== 'admin' && u.is_active !== false);
|
||
const known = new Set(chatThreads.map(t => t.tutor_id));
|
||
const rest = tutors.filter(u => !known.has(u.id));
|
||
const pool = rest.length ? rest : tutors;
|
||
select.innerHTML = pool.map(u => `<option value="${u.id}">${esc(u.name || u.username)} (@${esc(u.username)})</option>`).join('')
|
||
|| '<option value="">Нет доступных тьюторов</option>';
|
||
} catch (e) {
|
||
showToast(e.message || 'Не удалось загрузить пользователей', true);
|
||
return;
|
||
}
|
||
modal.classList.add('open');
|
||
}
|
||
|
||
async function sendChatMessage() {
|
||
if (chatSending || !chatActiveId) return;
|
||
const input = document.getElementById('chatInput');
|
||
const body = String(input.value || '').trim();
|
||
const files = chatPendingFiles.slice();
|
||
if (!body && !files.length) return;
|
||
chatSending = true;
|
||
const sendBtn = document.getElementById('chatSendBtn');
|
||
if (sendBtn) sendBtn.disabled = true;
|
||
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();
|
||
if (data.message) addChatMessage(data.message);
|
||
const thread = chatThreads.find(t => t.id === chatActiveId);
|
||
if (thread) {
|
||
thread.last_message_text = (data.message && data.message.body) || `Вложение: ${files[0] ? files[0].name : ''}`;
|
||
thread.last_message_at = data.message ? data.message.created_at : thread.last_message_at;
|
||
}
|
||
renderChatThreads();
|
||
renderChatMessages();
|
||
if (stick) scrollChatDown();
|
||
} catch (e) {
|
||
showToast(e.message || 'Сообщение не отправлено', true);
|
||
} finally {
|
||
chatSending = false;
|
||
if (sendBtn) sendBtn.disabled = false;
|
||
input.focus();
|
||
}
|
||
}
|
||
|
||
async function submitChatNew() {
|
||
const select = document.getElementById('chatNewUser');
|
||
const modal = document.getElementById('chatNewModal');
|
||
const userId = Number(select.value);
|
||
if (!userId) {
|
||
showToast('Выберите тьютора', true);
|
||
return;
|
||
}
|
||
try {
|
||
const data = await chatApi('/api/chat/threads', { method: 'POST', headers: hdrJson(), body: JSON.stringify({ user_id: userId }) });
|
||
if (modal) modal.classList.remove('open');
|
||
if (data.thread) {
|
||
if (!chatThreads.some(t => t.id === data.thread.id)) chatThreads.unshift(data.thread);
|
||
renderChatThreads();
|
||
selectChatThread(data.thread.id);
|
||
}
|
||
} catch (e) {
|
||
showToast(e.message || 'Не удалось создать диалог', true);
|
||
}
|
||
}
|
||
|
||
async function ensureOwnThread() {
|
||
try {
|
||
const data = await chatApi('/api/chat/threads', { method: 'POST', headers: hdrJson(), body: '{}' });
|
||
if (data.thread) {
|
||
if (!chatThreads.some(t => t.id === data.thread.id)) {
|
||
chatThreads.unshift(data.thread);
|
||
renderChatThreads();
|
||
}
|
||
return data.thread.id;
|
||
}
|
||
} catch {}
|
||
return 0;
|
||
}
|
||
|
||
function initChatPage() {
|
||
const list = document.getElementById('chatThreadList');
|
||
const input = document.getElementById('chatInput');
|
||
const fileInput = document.getElementById('chatFileInput');
|
||
const attachBtn = document.getElementById('chatAttachBtn');
|
||
const sendBtn = document.getElementById('chatSendBtn');
|
||
const reloadBtn = document.getElementById('chatReloadBtn');
|
||
const newBtn = document.getElementById('chatNewBtn');
|
||
const hiddenBtn = document.getElementById('chatHiddenBtn');
|
||
const newModal = document.getElementById('chatNewModal');
|
||
const newCancel = document.getElementById('chatNewCancel');
|
||
const newOk = document.getElementById('chatNewOk');
|
||
const attachBox = document.getElementById('chatAttachList');
|
||
const imgModal = document.getElementById('imgModal');
|
||
const box = document.getElementById('chatMessages');
|
||
const editModal = document.getElementById('chatEditModal');
|
||
const editInput = document.getElementById('chatEditText');
|
||
const editCancel = document.getElementById('chatEditCancel');
|
||
const editSave = document.getElementById('chatEditSave');
|
||
|
||
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));
|
||
return;
|
||
}
|
||
const delBtn = e.target.closest('[data-msg-del]');
|
||
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) {
|
||
editModal.addEventListener('click', e => { if (e.target === editModal) closeChatEditModal(); });
|
||
}
|
||
if (editInput) {
|
||
editInput.addEventListener('input', () => autoGrowChatInput(editInput));
|
||
editInput.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||
e.preventDefault();
|
||
submitChatEdit();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (list) {
|
||
list.addEventListener('click', e => {
|
||
const hideBtn = e.target.closest('[data-thread-hide]');
|
||
if (hideBtn) { e.stopPropagation(); hideChatThread(Number(hideBtn.dataset.threadHide)); return; }
|
||
const restoreBtn = e.target.closest('[data-thread-restore]');
|
||
if (restoreBtn) { e.stopPropagation(); restoreChatThread(Number(restoreBtn.dataset.threadRestore)); return; }
|
||
const purgeBtn = e.target.closest('[data-thread-purge]');
|
||
if (purgeBtn) { e.stopPropagation(); purgeChatThread(Number(purgeBtn.dataset.threadPurge)); return; }
|
||
const btn = e.target.closest('[data-thread]');
|
||
if (!btn) return;
|
||
selectChatThread(Number(btn.dataset.thread));
|
||
});
|
||
list.addEventListener('keydown', e => {
|
||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||
if (e.target.closest('[data-thread-hide],[data-thread-restore],[data-thread-purge]')) return;
|
||
const card = e.target.closest('.chat-thread[data-thread]');
|
||
if (!card || e.target !== card) return;
|
||
e.preventDefault();
|
||
selectChatThread(Number(card.dataset.thread));
|
||
});
|
||
}
|
||
if (attachBox) {
|
||
attachBox.addEventListener('click', e => {
|
||
const btn = e.target.closest('[data-remove]');
|
||
if (btn) removeChatFile(Number(btn.dataset.remove));
|
||
});
|
||
}
|
||
if (sendBtn) sendBtn.addEventListener('click', sendChatMessage);
|
||
if (reloadBtn) reloadBtn.addEventListener('click', () => { loadChatThreads(); loadChatMessages(); });
|
||
if (attachBtn) attachBtn.addEventListener('click', () => fileInput && fileInput.click());
|
||
if (fileInput) {
|
||
fileInput.addEventListener('change', () => {
|
||
addChatFiles([...fileInput.files]);
|
||
fileInput.value = '';
|
||
});
|
||
}
|
||
if (input) {
|
||
input.addEventListener('input', autoGrowChatInput);
|
||
input.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendChatMessage();
|
||
}
|
||
});
|
||
}
|
||
if (newBtn) newBtn.addEventListener('click', openChatNewModal);
|
||
if (hiddenBtn) hiddenBtn.addEventListener('click', () => {
|
||
chatHiddenMode = !chatHiddenMode;
|
||
chatActiveId = 0;
|
||
renderChatHiddenBtn();
|
||
loadChatThreads();
|
||
});
|
||
if (newCancel) newCancel.addEventListener('click', () => newModal && newModal.classList.remove('open'));
|
||
if (newModal) {
|
||
newModal.addEventListener('click', e => { if (e.target === newModal) newModal.classList.remove('open'); });
|
||
}
|
||
if (newOk) newOk.addEventListener('click', submitChatNew);
|
||
|
||
document.addEventListener('paste', e => {
|
||
if (!chatActiveId) return;
|
||
const active = document.activeElement;
|
||
if (active && active !== input && active !== document.body && !(attachBox && attachBox.contains(active))) return;
|
||
const clip = e.clipboardData;
|
||
if (!clip) return;
|
||
const pasted = [];
|
||
for (const item of Array.from(clip.items || [])) {
|
||
if (item.kind !== 'file') continue;
|
||
const file = item.getAsFile();
|
||
if (file) pasted.push(file);
|
||
}
|
||
if (!pasted.length && clip.files && clip.files.length) pasted.push(...Array.from(clip.files));
|
||
if (!pasted.length) return;
|
||
e.preventDefault();
|
||
const normalized = pasted.map(raw => {
|
||
const name = chatPasteFileName(raw);
|
||
if (raw.name === name) return raw;
|
||
return new File([raw], name, { type: raw.type || 'application/octet-stream', lastModified: raw.lastModified });
|
||
});
|
||
if (addChatFiles(normalized)) showToast(normalized.length === 1 ? 'Файл прикреплён' : `Файлов прикреплено: ${normalized.length}`);
|
||
});
|
||
|
||
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');
|
||
});
|
||
|
||
if (imgModal) {
|
||
imgModal.addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||
}
|
||
|
||
if (newBtn) newBtn.hidden = !isAdmin();
|
||
const side = document.getElementById('chatSide');
|
||
const layout = document.getElementById('chatLayout');
|
||
if (!isAdmin() && side) side.hidden = true;
|
||
if (!isAdmin() && layout) layout.classList.add('chat-layout-solo');
|
||
const emptySub = document.querySelector('#chatEmpty .chat-empty-sub');
|
||
if (emptySub && !isAdmin()) emptySub.textContent = 'Напишите администратору — сообщения доставятся мгновенно';
|
||
|
||
loadChatThreads().then(async () => {
|
||
if (!isAdmin()) {
|
||
const id = chatThreads[0] ? chatThreads[0].id : await ensureOwnThread();
|
||
if (id) selectChatThread(id);
|
||
else renderChatHeader();
|
||
} else if (chatThreads[0]) {
|
||
selectChatThread(chatThreads[0].id);
|
||
} else {
|
||
renderChatHeader();
|
||
renderChatMessages();
|
||
}
|
||
});
|
||
}
|
||
|
||
(async function () {
|
||
if (!(await checkAuth())) return;
|
||
buildSidebar(document.body.dataset.page);
|
||
const status = document.getElementById('authStatus');
|
||
if (status) {
|
||
status.textContent = '';
|
||
status.classList.add('loaded');
|
||
}
|
||
initChatPage();
|
||
})();
|
||
|