feat(chat): личный чат тьютор ↔ админ с вложениями
Диалоги 1:1 (один тред на тьютора), отдельный SSE-канал whatido:chat вместо общей ленты notifications, вложения с белым списком расширений и вставкой из буфера (Ctrl+V), очистка по chat_retention_days. Таблицы в бэкап, chat_attachments добавлен в sweepOrphanedUploads.
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
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;
|
||||
|
||||
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() || 'Нет сообщений';
|
||||
return `<button type="button" class="chat-thread${active}${unread ? ' unread' : ''}" data-thread="${t.id}">
|
||||
<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>' : ''}
|
||||
<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>` : ''}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function renderChatThreads() {
|
||||
const list = document.getElementById('chatThreadList');
|
||||
if (!list) return;
|
||||
if (!chatThreads.length) {
|
||||
list.innerHTML = '<div class="chat-side-empty">Диалогов пока нет</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = chatThreads.map(chatThreadCardHtml).join('');
|
||||
}
|
||||
|
||||
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)}" target="_blank" rel="noopener">
|
||||
<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' ? 'администратор' : 'тьютор');
|
||||
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>` : ''}
|
||||
<span class="grow"></span>
|
||||
<span class="msg-time">${esc(chatMessageTime(m.created_at))}</span>
|
||||
</div>
|
||||
${m.body ? `<div class="msg-body">${esc(m.body)}</div>` : ''}
|
||||
${chatFilesHtml(m.files)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 renderChatMessages() {
|
||||
const box = document.getElementById('chatMessages');
|
||||
if (!box) return;
|
||||
if (!chatMessages.length) {
|
||||
box.innerHTML = '<div class="chat-msgs-empty">Сообщений пока нет. Напишите первым.</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = chatMessages.map(chatMessageHtml).join('');
|
||||
renderIcons();
|
||||
}
|
||||
|
||||
function scrollChatDown() {
|
||||
const box = document.getElementById('chatMessages');
|
||||
if (box) box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
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', { headers: hdr() });
|
||||
chatThreads = data.items || [];
|
||||
setChatUnread(data.unread);
|
||||
renderChatThreads();
|
||||
} catch {
|
||||
showToast('Не удалось загрузить диалоги', 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() {
|
||||
const input = document.getElementById('chatInput');
|
||||
if (!input) return;
|
||||
input.style.height = 'auto';
|
||||
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
|
||||
}
|
||||
|
||||
function selectChatThread(id) {
|
||||
chatActiveId = Number(id) || 0;
|
||||
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 {
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
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();
|
||||
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 newModal = document.getElementById('chatNewModal');
|
||||
const newCancel = document.getElementById('chatNewCancel');
|
||||
const newOk = document.getElementById('chatNewOk');
|
||||
const attachBox = document.getElementById('chatAttachList');
|
||||
|
||||
if (list) {
|
||||
list.addEventListener('click', e => {
|
||||
const btn = e.target.closest('[data-thread]');
|
||||
if (!btn) return;
|
||||
selectChatThread(Number(btn.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 (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' && newModal && newModal.classList.contains('open')) newModal.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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user