mi-proyecto/app/templates/admin_chat.html

572 lines
26 KiB
HTML

{% extends 'base.html' %}
{% block content %}
<style>
.messenger-shell { background:#ebedf0; border-radius:24px; overflow:hidden; min-height:74vh; box-shadow:0 20px 50px rgba(47,63,87,.10); position:relative; }
.messenger-sidebar { border-right:1px solid #dadee0; background:#ebedf0; }
.messenger-conv-toolbar { background:#ebedf0; padding:22px; border-bottom:1px solid #dadee0; }
.messenger-chat-area { background:#fff; }
.messenger-search-input { border:none; border-radius:14px; background:#fff; padding:12px 14px; box-shadow: inset 0 0 0 1px #dbe0e5; }
.messenger-search-input:focus { outline:none; box-shadow:0 0 0 3px rgba(28,161,193,.14), inset 0 0 0 1px #1ca1c1; }
.convList { max-height: calc(74vh - 124px); overflow:auto; }
.convItem { display:block; padding:0; border:none; border-bottom:1px solid #dadee0; text-decoration:none; color:inherit; background:transparent; transition:all .18s ease; }
.convItem:hover { background:#f4f5f9; color:inherit; }
.convItem.active { background:#fff; box-shadow: inset 3px 0 #1ca1c1; }
.convOverall { display:flex; align-items:center; padding:14px 12px; gap:14px; }
.convImg, .chatAvatarCircle, .memberAvatarCircle { flex-shrink:0; display:flex; align-items:center; justify-content:center; border-radius:50%; color:#fff; font-weight:700; letter-spacing:.02em; }
.convImg { width:48px; height:48px; background:linear-gradient(135deg,#1ca1c1,#5875ff); font-size:.92rem; }
.convImg.group { background:linear-gradient(135deg,#6d5dfc,#1ca1c1); }
.convContent { min-width:0; flex:1; }
.convSubrow { display:flex; align-items:baseline; gap:10px; }
.convName { font-weight:600; max-width:220px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
.convDate { margin-left:auto; color:#94a1b3; font-size:.78rem; white-space:nowrap; }
.convMessage { color:#475466; max-width:210px; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; font-size:.92rem; }
.convCounter { margin-left:auto; background:#1ca1c1; color:#fff; min-width:22px; height:22px; padding:0 6px; border-radius:999px; display:flex; align-items:center; justify-content:center; font-size:.76rem; font-weight:600; }
.chatHeader { padding:18px 24px; border-bottom:1px solid #edf0f3; display:flex; align-items:center; gap:18px; }
.chatHeaderInfo { min-width:0; flex:1; }
.chatTitle { font-size:1.08rem; font-weight:700; color:#273142; }
.chatMeta { display:flex; align-items:center; gap:8px; flex-wrap:wrap; color:#7b8797; font-size:.86rem; }
.userListStack { display:flex; align-items:center; }
.memberAvatarCircle { width:32px; height:32px; font-size:.74rem; border:2px solid #fff; margin-left:-10px; background:linear-gradient(135deg,#94a1b3,#475466); }
.memberAvatarCircle:first-child { margin-left:0; }
.memberCounter { width:32px; height:32px; border-radius:50%; background:#94a1b3; color:#fff; display:flex; align-items:center; justify-content:center; font-size:.74rem; font-weight:700; margin-left:-10px; border:2px solid #fff; }
.chatToolbarBtn { width:40px; height:40px; border-radius:50%; border:none; background:#f4f5f9; color:#475466; }
.chatMessagesWrap { background:linear-gradient(180deg,#fff 0%, #f8fafc 100%); min-height:420px; max-height:calc(74vh - 230px); overflow:auto; padding:24px; }
.chatRow { display:flex; margin-bottom:16px; }
.chatRow.me { justify-content:flex-end; }
.chatBubbleWrap { max-width:78%; display:flex; gap:12px; align-items:flex-end; }
.chatRow.me .chatBubbleWrap { flex-direction:row-reverse; }
.chatAvatarCircle { width:40px; height:40px; font-size:.78rem; background:linear-gradient(135deg,#1ca1c1,#475466); }
.commentCard { flex:1; background:#f4f5f9; border-radius:0 18px 18px 18px; padding:12px 16px 14px; box-shadow:0 6px 18px rgba(77,94,112,.05); }
.chatRow.me .commentCard { background:#1ca1c1; color:#fff; border-radius:18px 0 18px 18px; }
.commentHeader { display:flex; align-items:center; gap:8px; font-size:.76rem; margin-bottom:4px; color:#7b8797; }
.chatRow.me .commentHeader { color:rgba(255,255,255,.82); }
.commentName { font-weight:700; }
.commentTime { margin-left:auto; }
.commentBody { white-space:pre-wrap; word-break:break-word; line-height:1.45; font-size:.94rem; }
.attachmentPill { display:inline-flex; align-items:center; gap:8px; margin-top:10px; border-radius:999px; padding:8px 12px; text-decoration:none; font-size:.84rem; background:#fff; color:#334155; box-shadow: inset 0 0 0 1px rgba(148,161,179,.35); }
.chatRow.me .attachmentPill { background:rgba(255,255,255,.18); color:#fff; box-shadow: inset 0 0 0 1px rgba(255,255,255,.24); }
.chatComposer { border-top:1px solid #edf0f3; padding:18px 24px; background:#fff; }
.composerBox { border-radius:22px; background:#f4f5f9; padding:14px; box-shadow: inset 0 0 0 1px #dde3ea; }
.composerBox textarea { resize:none; border:none; background:transparent; box-shadow:none !important; min-height:54px; }
.composerBottom { display:flex; gap:12px; align-items:center; justify-content:space-between; flex-wrap:wrap; }
.composerFile { display:flex; align-items:center; gap:8px; color:#64748b; font-size:.85rem; }
.composerFile input { max-width:280px; }
.empty-chat-state { display:flex; align-items:center; justify-content:center; flex-direction:column; text-align:center; min-height:540px; padding:40px 22px; color:#64748b; }
.empty-chat-state .bigIcon { width:92px; height:92px; border-radius:50%; display:flex; align-items:center; justify-content:center; background:#f4f5f9; font-size:2.4rem; color:#1ca1c1; margin-bottom:18px; }
.sending-state { opacity:.6; pointer-events:none; }
.chat-mobile-toggle,
.chat-mobile-close {
width:44px;
height:44px;
border:0;
border-radius:14px;
background:#1ca1c1;
color:#fff;
display:inline-flex;
align-items:center;
justify-content:center;
box-shadow:0 10px 24px rgba(28,161,193,.24);
flex-shrink:0;
}
.chat-mobile-toggle i,
.chat-mobile-close i {
font-size:1.2rem;
line-height:1;
}
.chat-mobile-backdrop {
position:fixed;
inset:0;
background:rgba(15,23,42,.42);
opacity:0;
pointer-events:none;
transition:opacity .28s ease;
z-index:1040;
}
@media (max-width: 991.98px) {
.messenger-shell { border-radius:18px; overflow:visible; }
.chatHeader, .chatComposer, .chatMessagesWrap { padding-left:16px; padding-right:16px; }
.chatBubbleWrap { max-width:100%; }
.convList { max-height:none; }
.messenger-sidebar {
position:fixed;
top:0;
right:0;
bottom:0;
left:auto;
width:min(380px, 88vw);
max-width:100%;
height:100vh;
z-index:1050;
border-right:0;
border-left:1px solid #dadee0;
box-shadow:-18px 0 40px rgba(15,23,42,.18);
transform:translateX(100%);
transition:transform .30s ease;
display:flex;
flex-direction:column;
}
.messenger-conv-toolbar {
position:sticky;
top:0;
z-index:2;
}
.convList {
flex:1 1 auto;
overflow:auto;
}
.messenger-chat-area {
min-height:72vh;
}
#messengerApp.chat-sidebar-open .messenger-sidebar {
transform:translateX(0);
}
#messengerApp.chat-sidebar-open .chat-mobile-backdrop {
opacity:1;
pointer-events:auto;
}
}
</style>
<div class="page-toolbar">
<div>
<h1 class="h3 mb-1">Chat</h1>
<p class="text-muted mb-0">Mensajería privada, grupos, adjuntos y avisos en tiempo real. Cada conversación queda aislada por institución.</p>
</div>
<button type="button" class="chat-mobile-toggle d-lg-none" id="chatSidebarToggle" aria-label="Abrir conversaciones">
<i class="bi bi-list"></i>
</button>
</div>
<div
class="messenger-shell row g-0"
id="messengerApp"
data-current-user-id="{{ current_user.id }}"
data-active-conversation-id="{{ active_conversation.id if active_conversation else '' }}"
data-chat-base-url="{{ url_for('admin_chat') }}"
>
<div class="chat-mobile-backdrop" id="chatSidebarBackdrop"></div>
<aside class="col-lg-4 messenger-sidebar" id="chatSidebarPanel">
<div class="messenger-conv-toolbar">
<div class="d-flex justify-content-between align-items-center gap-2 mb-3">
<div>
<div class="fw-semibold" style="font-size:1.05rem; color:#273142;">Conversaciones ({{ conversations|length }})</div>
<div class="small text-muted">Privados y grupos de {{ current_institution.display_name if current_institution else 'la plataforma' }}</div>
</div>
<div class="d-flex align-items-center gap-2">
<button type="button" class="chat-mobile-close d-lg-none" id="chatSidebarClose" aria-label="Cerrar conversaciones">
<i class="bi bi-x-lg"></i>
</button>
<button class="btn btn-primary btn-sm rounded-circle shadow-sm" style="width:42px;height:42px;" data-bs-toggle="modal" data-bs-target="#newChatModal" aria-label="Nueva conversación">
<i class="bi bi-plus-lg"></i>
</button>
</div>
</div>
<input type="search" class="form-control messenger-search-input" id="conversationSearch" placeholder="Buscar conversación...">
</div>
<div class="convList" id="conversationList">
{% for item in conversations %}
{% set convo = item.conversation %}
{% set is_active = active_conversation and active_conversation.id == convo.id %}
{% set initials = (item.display_name or 'C').split() %}
<a
class="convItem {% if is_active %}active{% endif %}"
href="{{ url_for('admin_chat', conversation_id=convo.id) }}"
data-conversation-id="{{ convo.id }}"
data-search="{{ (item.display_name ~ ' ' ~ (item.last_message.body if item.last_message and item.last_message.body else ''))|lower }}"
>
<div class="convOverall">
<div class="convImg {% if convo.is_group %}group{% endif %}">{{ ((initials[0][0] if initials else 'C') ~ (initials[1][0] if initials|length > 1 else ''))|upper }}</div>
<div class="convContent">
<div class="convSubrow">
<span class="convName">{{ item.display_name }}</span>
<span class="convDate">{{ (item.last_message.created_at if item.last_message else convo.created_at).strftime('%H:%M') }}</span>
</div>
<div class="convSubrow">
<span class="convMessage">{{ item.last_message.body if item.last_message and item.last_message.body else ('Archivo adjunto' if item.last_message and item.last_message.attachments else 'Sin mensajes') }}</span>
<span class="convCounter {% if not item.unread %}d-none{% endif %}">{{ item.unread or 0 }}</span>
</div>
</div>
</div>
</a>
{% else %}
<div class="p-4 text-muted text-center">Todavía no hay conversaciones.</div>
{% endfor %}
</div>
</aside>
<div class="col-lg-8 messenger-chat-area">
{% if active_conversation %}
<div class="chatHeader">
<button type="button" class="chat-mobile-toggle d-lg-none" id="chatSidebarToggleInline" aria-label="Abrir conversaciones">
<i class="bi bi-list"></i>
</button>
<div class="chatAvatarCircle">{{ (active_chat_title or 'C')[:2]|upper }}</div>
<div class="chatHeaderInfo">
<div class="chatTitle">{{ active_chat_title }}</div>
<div class="chatMeta">
<span>{{ active_conversation.members|length }} integrante(s)</span>
<span></span>
<span>{% if active_conversation.is_group %}Grupo{% else %}Chat privado{% endif %}</span>
{% if active_conversation.institution %}
<span></span>
<span>{{ active_conversation.institution.display_name }}</span>
{% endif %}
</div>
</div>
<div class="userListStack" title="Integrantes">
{% set shown_members = active_conversation.members[:4] %}
{% for member in shown_members %}
<div class="memberAvatarCircle">{{ member.user.full_name[:2]|upper if member.user and member.user.full_name else 'US' }}</div>
{% endfor %}
{% if active_conversation.members|length > 4 %}
<div class="memberCounter">+{{ active_conversation.members|length - 4 }}</div>
{% endif %}
</div>
<button type="button" class="chatToolbarBtn" data-bs-toggle="modal" data-bs-target="#newChatModal" title="Nueva conversación">
<i class="bi bi-three-dots"></i>
</button>
</div>
<div class="chatMessagesWrap" id="chatMessagesContainer">
{% for message in active_messages %}
{% set sender_name = message.sender.full_name if message.sender else 'Sistema' %}
<div class="chatRow {% if message.sender_id == current_user.id %}me{% endif %}" data-message-id="{{ message.id }}">
<div class="chatBubbleWrap">
<div class="chatAvatarCircle">{{ sender_name[:2]|upper }}</div>
<div class="commentCard">
<div class="commentHeader">
<span class="commentName">{{ 'Vos' if message.sender_id == current_user.id else sender_name }}</span>
<span class="commentTime">{{ message.created_at.strftime('%H:%M') }}</span>
</div>
<div class="commentBody">{{ message.body }}</div>
{% for att in message.attachments %}
<a href="{{ url_for('static', filename=att.file_path) }}" class="attachmentPill" target="_blank">
<i class="bi bi-paperclip"></i> {{ att.filename }}
</a>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
</div>
<div class="chatComposer">
<form method="post" action="{{ url_for('admin_chat') }}" enctype="multipart/form-data" id="chatForm">
<input type="hidden" name="action" value="send_message">
<input type="hidden" name="conversation_id" value="{{ active_conversation.id }}">
<div class="composerBox">
<textarea class="form-control" name="body" id="chatBody" rows="3" placeholder="Escribí tu mensaje..."></textarea>
<div class="composerBottom pt-2">
<label class="composerFile mb-0">
<i class="bi bi-paperclip"></i>
<span>Adjuntar archivo</span>
<input type="file" class="form-control form-control-sm" name="chat_file" id="chatFileInput">
</label>
<button class="btn btn-primary px-4" id="chatSendButton">
<i class="bi bi-send me-1"></i> Enviar
</button>
</div>
</div>
</form>
</div>
{% else %}
<div class="empty-chat-state">
<div class="bigIcon"><i class="bi bi-chat-dots"></i></div>
<h3 class="mb-2">Seleccioná una conversación</h3>
<p class="mb-4">Podés abrir un chat privado o crear un grupo desde el botón +.</p>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#newChatModal">
<i class="bi bi-plus-lg me-1"></i> Nueva conversación
</button>
</div>
{% endif %}
</div>
</div>
<div class="modal fade" id="newChatModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header">
<h5 class="modal-title fw-bold">Nueva conversación</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<ul class="nav nav-pills nav-fill mb-4" id="chatTab" role="tablist">
<li class="nav-item">
<button class="nav-link active" id="private-tab" data-bs-toggle="pill" data-bs-target="#private" type="button">Chat Privado</button>
</li>
<li class="nav-item">
<button class="nav-link" id="group-tab" data-bs-toggle="pill" data-bs-target="#group" type="button">Nuevo Grupo</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="private">
<form method="post" action="{{ url_for('admin_chat') }}">
<input type="hidden" name="action" value="start_private">
<div class="mb-3">
<label class="form-label">Usuario</label>
<select class="form-select" name="target_user_id" required>
<option value="">Seleccionar...</option>
{% for user in available_users %}
<option value="{{ user.id }}">{{ user.full_name }} ({{ user.role }}{% if user.institution %} · {{ user.institution.display_name }}{% else %} · plataforma{% endif %})</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary w-100">Abrir chat</button>
</form>
</div>
<div class="tab-pane fade" id="group">
<form method="post" action="{{ url_for('admin_chat') }}">
<input type="hidden" name="action" value="create_group">
<div class="mb-3">
<label class="form-label">Nombre del grupo</label>
<input class="form-control" name="title" required>
</div>
<div class="mb-3">
<label class="form-label">Integrantes</label>
<select class="form-select" name="member_ids" multiple required size="5">
{% for user in available_users %}
<option value="{{ user.id }}">{{ user.full_name }}{% if user.institution %} · {{ user.institution.display_name }}{% else %} · plataforma{% endif %}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-outline-primary w-100">Crear grupo</button>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function(){
const appRoot = document.getElementById('messengerApp');
if (!appRoot) return;
const currentUserId = String(appRoot.dataset.currentUserId || '');
const currentConversationId = String(appRoot.dataset.activeConversationId || '');
const chatBaseUrl = String(appRoot.dataset.chatBaseUrl || '/chat');
const searchInput = document.getElementById('conversationSearch');
const conversationList = document.getElementById('conversationList');
const messagesContainer = document.getElementById('chatMessagesContainer');
const chatForm = document.getElementById('chatForm');
const chatBody = document.getElementById('chatBody');
const chatSendButton = document.getElementById('chatSendButton');
const chatSidebarToggle = document.getElementById('chatSidebarToggle');
const chatSidebarToggleInline = document.getElementById('chatSidebarToggleInline');
const chatSidebarClose = document.getElementById('chatSidebarClose');
const chatSidebarBackdrop = document.getElementById('chatSidebarBackdrop');
const knownMessages = new Set(Array.from(document.querySelectorAll('[data-message-id]')).map(el => String(el.dataset.messageId)));
let markReadTimer = null;
function isMobileChat() {
return window.innerWidth < 992;
}
function openChatSidebar() {
if (!isMobileChat()) return;
appRoot.classList.add('chat-sidebar-open');
document.body.classList.add('sidebar-lock');
}
function closeChatSidebar() {
appRoot.classList.remove('chat-sidebar-open');
document.body.classList.remove('sidebar-lock');
}
if (chatSidebarToggle) {
chatSidebarToggle.addEventListener('click', openChatSidebar);
}
if (chatSidebarToggleInline) {
chatSidebarToggleInline.addEventListener('click', openChatSidebar);
}
if (chatSidebarClose) {
chatSidebarClose.addEventListener('click', closeChatSidebar);
}
if (chatSidebarBackdrop) {
chatSidebarBackdrop.addEventListener('click', closeChatSidebar);
}
window.addEventListener('resize', function () {
if (!isMobileChat()) {
closeChatSidebar();
}
});
if (conversationList) {
conversationList.addEventListener('click', function(ev){
const item = ev.target.closest('.convItem');
if (item && isMobileChat()) {
closeChatSidebar();
}
});
}
function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, function(ch){
return ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]);
});
}
function initials(name) {
const parts = String(name || 'Chat').trim().split(/\s+/).filter(Boolean);
return ((parts[0] ? parts[0][0] : 'C') + (parts[1] ? parts[1][0] : '')).toUpperCase();
}
function scrollToBottom() {
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
function attachmentHtml(att) {
return `<a href="${escapeHtml(att.url)}" class="attachmentPill" target="_blank"><i class="bi bi-paperclip"></i> ${escapeHtml(att.filename || 'Adjunto')}</a>`;
}
function messageHtml(message) {
const isMine = String(message.sender_id) === currentUserId;
const body = escapeHtml(message.body || '');
const attachments = (message.attachments || []).map(attachmentHtml).join('');
return `<div class="chatRow ${isMine ? 'me' : ''}" data-message-id="${message.message_id}"><div class="chatBubbleWrap"><div class="chatAvatarCircle">${initials(message.sender_name || 'Sistema')}</div><div class="commentCard"><div class="commentHeader"><span class="commentName">${isMine ? 'Vos' : escapeHtml(message.sender_name || 'Sistema')}</span><span class="commentTime">${escapeHtml(message.created_time || '')}</span></div><div class="commentBody">${body.replace(/\n/g, '<br>')}</div>${attachments}</div></div></div>`;
}
function appendMessage(message) {
if (!messagesContainer || !message || knownMessages.has(String(message.message_id))) return;
knownMessages.add(String(message.message_id));
messagesContainer.insertAdjacentHTML('beforeend', messageHtml(message));
scrollToBottom();
}
function conversationItemHtml(conversation, active) {
const unread = Number(conversation.unread || 0);
const search = `${conversation.display_name || ''} ${conversation.last_message || ''}`.toLowerCase();
const href = `${chatBaseUrl}?conversation_id=${conversation.id}`;
return `<a class="convItem ${active ? 'active' : ''}" href="${href}" data-conversation-id="${conversation.id}" data-search="${escapeHtml(search)}"><div class="convOverall"><div class="convImg ${conversation.is_group ? 'group' : ''}">${initials(conversation.display_name || 'Chat')}</div><div class="convContent"><div class="convSubrow"><span class="convName">${escapeHtml(conversation.display_name || 'Chat')}</span><span class="convDate">${escapeHtml(conversation.last_message_short_time || '')}</span></div><div class="convSubrow"><span class="convMessage">${escapeHtml(conversation.last_message || 'Sin mensajes')}</span><span class="convCounter ${unread ? '' : 'd-none'}">${unread}</span></div></div></div></a>`;
}
function upsertConversation(conversation) {
if (!conversationList || !conversation) return;
const existing = conversationList.querySelector(`[data-conversation-id="${conversation.id}"]`);
const active = String(conversation.id) === currentConversationId;
if (existing) existing.remove();
conversationList.insertAdjacentHTML('afterbegin', conversationItemHtml(conversation, active));
}
function markRead() {
if (!currentConversationId) return;
const fd = new FormData();
fd.append('conversation_id', currentConversationId);
fetch('/api/chat/mark-read', {
method:'POST',
body:fd,
headers:{'X-Requested-With':'XMLHttpRequest'}
}).catch(() => {});
}
function scheduleMarkRead() {
clearTimeout(markReadTimer);
markReadTimer = setTimeout(markRead, 400);
}
if (searchInput && conversationList) {
searchInput.addEventListener('input', function() {
const term = (searchInput.value || '').toLowerCase().trim();
conversationList.querySelectorAll('.convItem').forEach(function(item){
item.style.display = !term || (item.dataset.search || '').includes(term) ? '' : 'none';
});
});
}
if (chatForm && chatSendButton) {
chatForm.addEventListener('submit', async function(ev){
ev.preventDefault();
const fileField = chatForm.querySelector('input[name="chat_file"]');
const bodyText = (chatBody && chatBody.value || '').trim();
if (!bodyText && !(fileField && fileField.files && fileField.files.length)) return;
chatSendButton.disabled = true;
chatForm.classList.add('sending-state');
try {
const payload = new FormData(chatForm);
const res = await fetch('/api/chat/send', {
method:'POST',
body:payload,
headers:{'X-Requested-With':'XMLHttpRequest'}
});
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error || 'No se pudo enviar el mensaje.');
if (chatBody) chatBody.value = '';
if (fileField) fileField.value = '';
scheduleMarkRead();
} catch (error) {
alert(error.message || 'No se pudo enviar el mensaje.');
} finally {
chatSendButton.disabled = false;
chatForm.classList.remove('sending-state');
if (chatBody) chatBody.focus();
}
});
}
document.addEventListener('appchat:notify', function(event){
const data = event.detail || {};
if (data.conversation) {
upsertConversation(data.conversation);
}
if (data.message && String(data.message.conversation_id) === currentConversationId) {
appendMessage(data.message);
scheduleMarkRead();
}
});
if (window.AppChat && window.AppChat.socket && currentConversationId) {
window.AppChat.socket.emit('join', { room: `chat_${currentConversationId}` });
}
if (messagesContainer) {
scrollToBottom();
scheduleMarkRead();
document.addEventListener('visibilitychange', function(){
if (!document.hidden) scheduleMarkRead();
});
window.addEventListener('focus', scheduleMarkRead);
}
})();
</script>
{% endblock %}