- изображения во вложениях чата открываются в общей модалке #imgModal (data-img + общий обработчик в admin.js) вместо новой вкладки - Escape и клик по фону закрывают модалку изображения - .chat-side[hidden] скрывается — раньше правило display:none его не покрывало - страница уведомлений открывается любому авторизованному пользователю (checkAuth вместо requireAdminPage), пункт меню виден всем
537 lines
19 KiB
JavaScript
537 lines
19 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;
|
|
|
|
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)}" 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' ? 'администратор' : 'тьютор');
|
|
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');
|
|
const imgModal = document.getElementById('imgModal');
|
|
|
|
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') 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();
|
|
})();
|
|
|