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 ? ` ` : ` `) : ''; return `
${esc((t.tutor_name || '?').slice(0, 1).toUpperCase())} ${esc(t.tutor_name || t.tutor_username)} ${t.tutor_active === false ? 'отключён' : ''} ${t.deleted_at ? 'скрыт' : ''} ${esc(chatThreadTime(t.last_message_at || t.created_at))} ${esc(preview)} ${unread ? `${unread > 99 ? '99+' : unread}` : ''} ${acts}
`; } function renderChatThreads() { const list = document.getElementById('chatThreadList'); if (!list) return; if (!chatThreads.length) { list.innerHTML = `
${chatHiddenMode ? 'Скрытых диалогов нет' : 'Диалогов пока нет'}
`; return; } list.innerHTML = chatThreads.map(chatThreadCardHtml).join(''); renderIcons(); } function chatFilesHtml(files) { if (!files || !files.length) return ''; return `
${files.map(f => { if (CHAT_IMAGE_EXT.test(f.name || '')) { return ` ${esc(f.name)} ${esc(f.name)} `; } return ` ${esc(f.name)} ${chatHumanSize(f.size)} `; }).join('')}
`; } 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 `
${esc(who)} ${role ? `${esc(role)}` : ''} ${m.edited_at ? `изменено` : ''} ${esc(chatMessageTime(m.created_at))} ${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; 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 = '
Сообщений пока нет. Напишите первым.
'; 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) => ` ${esc(f.name)} ${chatHumanSize(f.size)} `).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 ? `` : `${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(); } 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 => ``).join('') || ''; } 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(); })();