feat: smart scroll — keep position when reading, badge for new messages

- Add pollMensajes() so wire:poll no longer force-scrolls to bottom
- MutationObserver detects new DOM nodes: auto-scrolls only if user
  is already at the bottom (within 80px threshold)
- Floating '↓ N mensaje nuevo' badge appears when new bot messages
  arrive while user is scrolled up; tap badge to jump to bottom
- scroll-chat event (emitted on button clicks/sends) still forces
  scroll since the user just took an action

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro
2026-07-14 13:36:31 +00:00
co-authored by Claude Sonnet 4.6
parent a5bac9cee7
commit 26953c9dd5
3 changed files with 132 additions and 24 deletions
+95 -16
View File
@@ -104,30 +104,109 @@
@livewireScripts
<script>
/* Ajusta altura del chat cuando aparece el teclado en móvil */
function initViewportFix() {
(function () {
const BOTTOM_THRESHOLD = 80; // px del fondo = "está al fondo"
let isAtBottom = true;
let newMsgCount = 0;
let lastMsgCount = 0;
let msgsEl = null;
let observer = null;
/* ── Helpers ── */
function countMsgs() {
return msgsEl ? msgsEl.querySelectorAll(':scope > div').length : 0;
}
function checkBottom() {
if (!msgsEl) return true;
return (msgsEl.scrollHeight - msgsEl.scrollTop - msgsEl.clientHeight) <= BOTTOM_THRESHOLD;
}
function scrollBottom(smooth) {
if (msgsEl) msgsEl.scrollTo({ top: msgsEl.scrollHeight, behavior: smooth ? 'smooth' : 'instant' });
}
function updateBadge() {
const badge = document.getElementById('new-msg-badge');
const count = document.getElementById('new-msg-count');
if (!badge) return;
if (newMsgCount > 0 && !isAtBottom) {
if (count) count.textContent = newMsgCount;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
/* ── Inicializar ── */
function init() {
msgsEl = document.getElementById('pub-messages');
if (!msgsEl) return;
lastMsgCount = countMsgs();
// Rastrear posición de scroll
msgsEl.addEventListener('scroll', () => {
isAtBottom = checkBottom();
if (isAtBottom) {
newMsgCount = 0;
updateBadge();
}
}, { passive: true });
// Detectar mensajes nuevos sin hacer scroll invasivo
if (observer) observer.disconnect();
observer = new MutationObserver(() => {
const current = countMsgs();
const diff = current - lastMsgCount;
lastMsgCount = current;
if (diff <= 0) return;
if (isAtBottom) {
scrollBottom(false);
} else {
newMsgCount += diff;
updateBadge();
}
});
observer.observe(msgsEl, { childList: true });
// Badge: clic → bajar
const btn = document.getElementById('new-msg-badge-btn');
if (btn && !btn._scrollBound) {
btn._scrollBound = true;
btn.addEventListener('click', () => {
newMsgCount = 0;
isAtBottom = true;
scrollBottom(true);
updateBadge();
});
}
}
/* ── scroll-chat: emitido por PHP al enviar/click botón ── */
window.addEventListener('scroll-chat', () => {
// El usuario acaba de interactuar → siempre bajar
isAtBottom = true;
newMsgCount = 0;
updateBadge();
scrollBottom(false);
});
/* ── Ajuste de altura cuando aparece el teclado en móvil ── */
function initViewport() {
const root = document.getElementById('chat-root');
if (!root || !window.visualViewport) return;
const update = () => {
const vh = window.visualViewport.height;
// En móvil: ajustamos #chat-root a la altura visual real
if (window.innerWidth < 640) {
root.style.height = vh + 'px';
}
// Scroll al fondo siempre que cambie el viewport
const msgs = document.getElementById('pub-messages');
if (msgs) {
requestAnimationFrame(() => { msgs.scrollTop = msgs.scrollHeight; });
}
if (window.innerWidth < 640) root.style.height = window.visualViewport.height + 'px';
if (isAtBottom && msgsEl) scrollBottom(false);
};
window.visualViewport.addEventListener('resize', update);
update();
}
document.addEventListener('DOMContentLoaded', initViewportFix);
document.addEventListener('livewire:load', initViewportFix);
document.addEventListener('DOMContentLoaded', () => { init(); initViewport(); });
document.addEventListener('livewire:load', () => { init(); initViewport(); });
// Re-init después de cada actualización de Livewire (por si el DOM se reinicia)
document.addEventListener('livewire:update', () => { setTimeout(init, 50); });
})();
</script>
</body>
</html>