From 267805d5cd2eef4edbf2c0833de75fbb6d552c6f Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Tue, 27 Jan 2026 00:12:04 -0500 Subject: [PATCH] up --- api/get_user_messages.php | 56 ++++-- api/version/media-url_debug.log | 33 +++ conversations.php | 342 ++++++++++++++++++++++++++++---- logs/system.log | 1 + manifest.json | 15 ++ service-worker.js | 11 + 6 files changed, 404 insertions(+), 54 deletions(-) create mode 100644 manifest.json create mode 100644 service-worker.js diff --git a/api/get_user_messages.php b/api/get_user_messages.php index 3dae4e8..1e5df70 100644 --- a/api/get_user_messages.php +++ b/api/get_user_messages.php @@ -27,6 +27,8 @@ try { $limit = isset($_GET['limit']) ? intval($_GET['limit']) : 50; $limit = max(1, min(200, $limit)); $before = !empty($_GET['before']) ? $_GET['before'] : null; // expect timestamp string + // We also support a 'since' parameter to fetch only messages strictly newer than a timestamp + $since = !empty($_GET['since']) ? $_GET['since'] : null; // Validar formato de 'before' si se proporcionó (evitar SQL/parse errors por input malformado) if (!is_null($before)) { @@ -38,10 +40,19 @@ try { } } + // Validar 'since' si se pasó + if (!is_null($since)) { + if (!preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $since)) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'since timestamp inválido']); + exit; + } + } + $db = Database::getInstance(); - // Construir consulta: obtener mensajes ordenados DESC (más recientes primero) y limitar - // Evitar ambigüedades prefijando columnas con el alias de la tabla (c) + // Construir consulta: by default paginamos hacia atrás (before) y ordenamos DESC + // Pero cuando se solicita 'since' queremos sólo mensajes más nuevos => orden ASC (para retornar cronológicamente) $sql = "SELECT c.id as id, c.user_id as user_id, @@ -65,20 +76,28 @@ try { $beforeId = isset($_GET['before_id']) ? intval($_GET['before_id']) : null; $params = ['user_id' => $userId]; - if ($before) { - $sql .= " AND (c.created_at < :before_lt"; - $params['before_lt'] = $before; - if ($beforeId) { - // Incluir mensajes con mismo timestamp pero id menor (paginación estable) - // Usamos placeholder distinto para evitar duplicar el mismo nombre en la query - $sql .= " OR (c.created_at = :before_eq AND c.id < :before_id)"; - $params['before_eq'] = $before; - $params['before_id'] = $beforeId; - } - $sql .= ")"; - } - $sql .= " ORDER BY c.created_at DESC, c.id DESC LIMIT " . $limit; + if (!is_null($since)) { + // If 'since' is provided, return only messages strictly newer than 'since' ordered ascending + $sql .= " AND c.created_at > :since"; + $params['since'] = $since; + $sql .= " ORDER BY c.created_at ASC, c.id ASC LIMIT " . $limit; + } else { + if ($before) { + $sql .= " AND (c.created_at < :before_lt"; + $params['before_lt'] = $before; + if ($beforeId) { + // Incluir mensajes con mismo timestamp pero id menor (paginación estable) + // Usamos placeholder distinto para evitar duplicar el mismo nombre en la query + $sql .= " OR (c.created_at = :before_eq AND c.id < :before_id)"; + $params['before_eq'] = $before; + $params['before_id'] = $beforeId; + } + $sql .= ")"; + } + + $sql .= " ORDER BY c.created_at DESC, c.id DESC LIMIT " . $limit; + } // Log params/consulta para diagnóstico si algo falla (no sensible) error_log('get_user_messages.php - params: ' . json_encode(['user_id' => $userId, 'limit' => $limit, 'before' => $before])); @@ -107,8 +126,11 @@ try { exit; } - // Queremos devolver los mensajes en orden cronológico ascendente para la UI - $conversations = array_reverse($conversations); + // Si se solicitó 'since' ya devolvimos en orden ASC (cronológico). Si no, revertimos + $sinceProvided = !is_null($since); + if (!$sinceProvided) { + $conversations = array_reverse($conversations); + } // Recalcular usando el resultado final para decidir paginación $finalCount = is_array($conversations) ? count($conversations) : 0; diff --git a/api/version/media-url_debug.log b/api/version/media-url_debug.log index c4484b0..aece599 100644 --- a/api/version/media-url_debug.log +++ b/api/version/media-url_debug.log @@ -62,3 +62,36 @@ [2026-01-21 22:05:03] Request: GET /api/version/media-url.php?id=3932473397057815 GET:{"id":"3932473397057815"} POST:[] [2026-01-21 22:05:03] Graph API request to https://graph.facebook.com/v22.0/3932473397057815 [2026-01-21 22:05:03] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=3932473397057815&source=getMedia&ext=1769051403&hash=ARm7CptuN3CH6WN5V1RmuW6rK9iIipE_KE1-FLbj3RQuHQ +[2026-01-26 23:32:27] Request: GET /api/version/media-url.php?id=744809485358700 GET:{"id":"744809485358700"} POST:[] +[2026-01-26 23:32:27] Graph API request to https://graph.facebook.com/v22.0/744809485358700 +[2026-01-26 23:32:28] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=744809485358700&source=getMedia&ext=1769488648&hash=ARk9shLmP6iyMkiJ5uh9-PvYmJ5nHAtiDbFy7aHsq4xfZw +[2026-01-26 23:32:53] Request: GET /api/version/media-url.php?id=744809485358700 GET:{"id":"744809485358700"} POST:[] +[2026-01-26 23:32:53] Graph API request to https://graph.facebook.com/v22.0/744809485358700 +[2026-01-26 23:32:53] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=744809485358700&source=getMedia&ext=1769488673&hash=ARnB_yLpfd4re-Gg3VWlmVMeBBUddIMvtwT9H1cyNVDm8w +[2026-01-26 23:38:15] Request: GET /api/version/media-url.php?id=744809485358700 GET:{"id":"744809485358700"} POST:[] +[2026-01-26 23:38:15] Graph API request to https://graph.facebook.com/v22.0/744809485358700 +[2026-01-26 23:38:15] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=744809485358700&source=getMedia&ext=1769488995&hash=ARmtsV162AJYLSTw4c5sSRcOqeNkSp3OpDEzblPHqrS-Pw +[2026-01-26 23:40:43] Request: GET /api/version/media-url.php?id=744809485358700 GET:{"id":"744809485358700"} POST:[] +[2026-01-26 23:40:43] Graph API request to https://graph.facebook.com/v22.0/744809485358700 +[2026-01-26 23:40:43] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=744809485358700&source=getMedia&ext=1769489143&hash=ARl38NJPww5Bl_g5a1g_M760-mftREt_0azvDz7X_0_PRA +[2026-01-26 23:48:33] Request: GET /api/version/media-url.php?id=744809485358700 GET:{"id":"744809485358700"} POST:[] +[2026-01-26 23:48:33] Graph API request to https://graph.facebook.com/v22.0/744809485358700 +[2026-01-26 23:48:33] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=744809485358700&source=getMedia&ext=1769489613&hash=ARn_ITiMj4Y1h8iPaKANBlnlUGWppfK74dzTfG34VaW5tQ +[2026-01-26 23:48:59] Request: GET /api/version/media-url.php?id=744809485358700 GET:{"id":"744809485358700"} POST:[] +[2026-01-26 23:48:59] Graph API request to https://graph.facebook.com/v22.0/744809485358700 +[2026-01-26 23:48:59] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=744809485358700&source=getMedia&ext=1769489639&hash=ARnKGDn0Cde1v5uNe54sObAocewL41pdZPPO_pJ_v7Y2fA +[2026-01-26 23:53:51] Request: GET /api/version/media-url.php?id=1387253256108589 GET:{"id":"1387253256108589"} POST:[] +[2026-01-26 23:53:51] Graph API request to https://graph.facebook.com/v22.0/1387253256108589 +[2026-01-26 23:53:52] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=1387253256108589&source=getMedia&ext=1769489932&hash=ARn_m8oP_45Y5WQnFTSdS8Z1GkIl7WlVVouVsoyAk5HiGw +[2026-01-26 23:54:25] Request: GET /api/version/media-url.php?id=744809485358700 GET:{"id":"744809485358700"} POST:[] +[2026-01-26 23:54:25] Graph API request to https://graph.facebook.com/v22.0/744809485358700 +[2026-01-26 23:54:25] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=744809485358700&source=getMedia&ext=1769489965&hash=ARmxdxTaoFR0Nng0iIoNK7adP9k4U7yzGDk6Ph-ei_uXkA +[2026-01-27 00:00:16] Request: GET /api/version/media-url.php?id=744809485358700 GET:{"id":"744809485358700"} POST:[] +[2026-01-27 00:00:16] Graph API request to https://graph.facebook.com/v22.0/744809485358700 +[2026-01-27 00:00:16] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=744809485358700&source=getMedia&ext=1769490316&hash=ARmpmDemk-gGukePX4CalHS0lLm_I-07G2copLrKz2KViw +[2026-01-27 00:03:27] Request: GET /api/version/media-url.php?id=744809485358700 GET:{"id":"744809485358700"} POST:[] +[2026-01-27 00:03:27] Graph API request to https://graph.facebook.com/v22.0/744809485358700 +[2026-01-27 00:03:27] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=744809485358700&source=getMedia&ext=1769490507&hash=ARn3anEAozRDy-NURAxHAGPhDXOeZjPOREUSpjXnYcvj9w +[2026-01-27 00:10:48] Request: GET /api/version/media-url.php?id=744809485358700 GET:{"id":"744809485358700"} POST:[] +[2026-01-27 00:10:48] Graph API request to https://graph.facebook.com/v22.0/744809485358700 +[2026-01-27 00:10:49] Proxying Facebook media URL with Authorization to https://lookaside.fbsbx.com/whatsapp_business/attachments/?mid=744809485358700&source=getMedia&ext=1769490949&hash=ARmzuq6pJSn6HumhTkmzK4COU2MX1aVjnFuz7CqAMzTzdA diff --git a/conversations.php b/conversations.php index 7b66089..007463b 100644 --- a/conversations.php +++ b/conversations.php @@ -1018,11 +1018,22 @@ // Track whether the user is actively scrolling/reading to avoid forcing scroll-to-bottom this._userScrolling = false; this._userScrollTimer = null; + + // Conversation list (sidebar) scroll tracking to preserve position on updates + this._conversationsScrolling = false; + this._conversationsScrollTimer = null; // Suspend periodic refresh while media playback or recording is active this._suspendAutoRefresh = false; this._pendingReloadAfterMedia = false; // Flag to request a reload after user stops scrolling this._pendingReloadAfterScroll = false; + // Staged incoming messages when the user is reading history (not near bottom) + this._stagedMessageIds = new Set(); + this._stagedCount = 0; + // Store actual staged message objects (for applying later) + this._stagedMessages = []; + // Last message timestamp seen (for delta polling) + this._latestMessage = null; // Removed: automatic "Nuevos mensajes" indicator — we always reload full conversation now // this._lastRenderMessageCount = 0; @@ -1711,7 +1722,29 @@ }); } window.addEventListener('appinstalled', () => console.debug('PWA installed (conversations)')); - if ('serviceWorker' in navigator) navigator.serviceWorker.register('/service-worker.js').then(() => console.debug('SW reg (conv)')).catch(e => console.warn('SW fail', e)); + if ('serviceWorker' in navigator) { + (async () => { + try { + // Check if the service-worker script is actually reachable to avoid noisy 404 warnings + const resp = await fetch('/service-worker.js', { method: 'GET', cache: 'no-store' }); + if (resp && resp.ok) { + navigator.serviceWorker.register('/service-worker.js') + .then(() => console.debug('SW reg (conv)')) + .catch(e => console.warn('SW reg failed', e)); + } else { + console.debug('Service worker not registered: script not found (status ' + (resp && resp.status) + ')'); + // Remove manifest link if it's missing to avoid extra 404s + try { + const link = document.querySelector('link[rel="manifest"]'); + if (link && (!resp || resp.status === 404)) link.parentNode && link.parentNode.removeChild(link); + } catch(e) {} + } + } catch (e) { + // Network or fetch failed — don't spam console with errors + console.debug('Service worker check failed:', e); + } + })(); + } } const stopRecBtn = document.getElementById('stop-recording'); @@ -1813,6 +1846,13 @@ const convList = document.getElementById('conversation-list'); if (convList) { convList.addEventListener('scroll', () => { + // mark user scrolling in sidebar so updates don't jump their view + try { + this._conversationsScrolling = true; + if (this._conversationsScrollTimer) clearTimeout(this._conversationsScrollTimer); + this._conversationsScrollTimer = setTimeout(() => { this._conversationsScrolling = false; this._conversationsScrollTimer = null; }, 600); + } catch (e) { /* ignore */ } + if (this.hasMoreConversations && !this.loadingConversations && (convList.scrollTop + convList.clientHeight >= convList.scrollHeight - 60)) { this.loadMoreConversations(); } @@ -1837,30 +1877,83 @@ this.loadConversations(); }, 30000); - // Actualizar mensajes del chat activo cada 10 segundos - // Use full reload (initial=true) — always fetch the full conversation and scroll to bottom, - // but suspend while media playback or recording is active (to avoid interrupting) + // Periodic polling to check only for new messages (delta), every 10s setInterval(() => { - if (this.currentUserId) { - try { - if (this._userScrolling) { - console.debug('Periodic load skipped: user is scrolling; scheduling reload after inactivity'); - this._pendingReloadAfterScroll = true; - return; - } + if (!this.currentUserId) return; + try { + if (this._userScrolling) { + console.debug('Periodic delta check skipped: user is scrolling; scheduling reload after inactivity'); + this._pendingReloadAfterScroll = true; + return; + } - if (typeof this.isMediaActive === 'function' && this.isMediaActive()) { - console.debug('Periodic load skipped: media active or recording in progress; scheduling reload after media ends'); - this._pendingReloadAfterMedia = true; - return; - } - } catch (e) { console.warn('Periodic checks failed', e); } - // full reload forces scroll-to-bottom - this.loadMessages(this.currentUserId, true).catch(err => console.warn('Periodic loadMessages failed', err)); - } + if (typeof this.isMediaActive === 'function' && this.isMediaActive()) { + console.debug('Periodic delta check skipped: media active or recording in progress; scheduling reload after media ends'); + this._pendingReloadAfterMedia = true; + return; + } + + this.pollNewMessages().catch(e => console.warn('pollNewMessages failed', e)); + } catch (e) { console.warn('Periodic checks failed', e); } }, 10000); } + // New: periodic delta poller to fetch only messages newer than last seen timestamp + async pollNewMessages() { + if (!this.currentUserId) return; + try { + // If we don't yet have a latest message timestamp, fall back to full load + if (!this._latestMessage) { + return await this.loadMessages(this.currentUserId, true, true); + } + + const url = `get_user_messages.php?user_id=${this.currentUserId}&since=${encodeURIComponent(this._latestMessage)}&limit=${this.messageLimit}`; + const resp = await this.apiCall(url); + if (!resp || !resp.success || !Array.isArray(resp.data) || resp.data.length === 0) return; + + const messages = resp.data; + // Deduplicate and process + const container = document.getElementById('chat-conversations'); + const nearBottomNow = container ? ((container.scrollHeight - container.scrollTop - container.clientHeight) < 150) : true; + + let appended = 0; + for (const m of messages) { + try { + // skip duplicates + const exists = this.conversations && this.conversations.some(x => (x.message_id && m.message_id && String(x.message_id) === String(m.message_id)) || (x.id && m.id && Number(x.id) === Number(m.id))); + if (exists) continue; + + if (nearBottomNow && !this._userScrolling) { + // append directly and mark for render + this.conversations.push(m); + appended++; + } else { + // stage for later (user is reading history) + this._stagedMessages.push(m); + if (this._stagedMessageIds) this._stagedMessageIds.add(m.message_id || m.id || ('local_' + Date.now())); + this._stagedCount = this._stagedMessages.length; + this.showNewMessagesIndicator(this._stagedCount); + } + } catch (e) { console.warn('pollNewMessages process failed for msg', e); } + } + + if (appended > 0) { + // render the new appended messages without disrupting playback/scroll; follow only if near bottom + this.renderMessagesIncremental(false, 0, nearBottomNow && !this._userScrolling); + if (nearBottomNow && !this._userScrolling) this.scrollToBottom(); + } + + // update latest timestamp from the last message + try { + const last = messages[messages.length - 1]; + if (last && last.created_at) this._latestMessage = last.created_at; + } catch (e) { /* ignore */ } + + } catch (e) { + console.warn('pollNewMessages failed', e); + } + } + // Helper para llamadas a la API desde esta clase async apiCall(endpoint, options = {}) { const defaultOptions = { @@ -2027,7 +2120,29 @@ this._prevConversations[c.user_id] = { unread_count: c.unread_count }; }); - container.innerHTML = html; + // Preserve sidebar scroll position as best-effort: snapshot and restore using delta + try { + const prevScrollTop = container.scrollTop; + const prevScrollHeight = container.scrollHeight; + const wasNearTop = prevScrollTop < 60; + + container.innerHTML = html; + + const newScrollHeight = container.scrollHeight; + const scrollDelta = newScrollHeight - prevScrollHeight; + + if (wasNearTop) { + // keep at top + container.scrollTop = 0; + } else { + // preserve visual offset (avoid jumping) whether the user is scrolling or not + container.scrollTop = Math.max(0, prevScrollTop + scrollDelta); + } + } catch (e) { + // fallback to naive replace if anything failed + console.warn('renderConversations: scroll preservation failed', e); + container.innerHTML = html; + } } getInitials(name) { @@ -2123,6 +2238,8 @@ async openConversation(userId, userName, phoneNumber) { this.currentUserId = userId; + // Clear any staged messages from a previous conversation and hide indicator + try { if (this._stagedMessageIds) { this._stagedMessageIds.clear(); this._stagedCount = 0; this.hideNewMessagesIndicator(); } } catch(e) {} // Actualizar UI document.getElementById('no-conversation').style.display = 'none'; @@ -2453,7 +2570,7 @@ this._userScrollTimer = null; if (this._pendingReloadAfterScroll && this.currentUserId) { this._pendingReloadAfterScroll = false; - try { this.loadMessages(this.currentUserId, true).catch(e => console.warn('reload after scroll failed', e)); } catch(e) { console.warn('reload after scroll schedule failed', e); } + try { this.loadMessages(this.currentUserId, true, false).catch(e => console.warn('reload after scroll failed', e)); } catch(e) { console.warn('reload after scroll schedule failed', e); } } }, 1500); } catch(e) { /* ignore */ } @@ -2461,6 +2578,14 @@ if (chatContainer.scrollTop <= 60 && this.hasMoreMessages && !this.loadingMessages && this.currentUserId == userId) { await this.loadMessages(userId, false); } + + // If the user scrolled near the bottom and there are staged messages, apply them + try { + const nearBottomNow = (chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight) < 150; + if (nearBottomNow && this._stagedMessages && this._stagedMessages.length) { + this.applyStagedMessages(); + } + } catch(e) { /* ignore */ } }); chatContainer._infiniteScrollAdded = true; } @@ -2479,7 +2604,7 @@ console.debug('Media paused/ended, resuming periodic reload'); if (this._pendingReloadAfterMedia && this.currentUserId) { this._pendingReloadAfterMedia = false; - await this.loadMessages(this.currentUserId, true); + await this.loadMessages(this.currentUserId, true, false); } } catch (e) { console.warn('media end handler failed', e); } }; @@ -2539,7 +2664,7 @@ /** * Cargar mensajes con paginación. Si initial=true, carga el bloque más reciente; si initial=false, carga mensajes anteriores (before=this.earliestMessage). */ - async loadMessages(userId, initial = false) { + async loadMessages(userId, initial = false, follow = true) { if (this.loadingMessages) return; this.loadingMessages = true; const container = document.getElementById('chat-conversations'); @@ -2663,6 +2788,14 @@ if (earliest) this.earliestMessage = earliest; if (typeof earliestId !== 'undefined' && earliestId !== null) this.earliestMessageId = earliestId; + // Update last/latest message timestamp so we can poll deltas later + try { + if (Array.isArray(this.conversations) && this.conversations.length > 0) { + const last = this.conversations[this.conversations.length - 1]; + if (last && last.created_at) this._latestMessage = last.created_at; + } + } catch(e) { /* ignore */ } + // Improved dedupe: pick the most "readable" message when duplicates exist try { if (!this.conversations || this.conversations.length === 0) { @@ -2751,7 +2884,7 @@ // Si cargamos paginación (initial=false), pasar la cantidad de mensajes recién obtenidos para insertarlos al inicio const newlyFetched = (!initial && Array.isArray(messages)) ? messages.length : 0; if (this.conversations && this.conversations.length > 0) { - this.renderMessagesIncremental(initial, newlyFetched); + this.renderMessagesIncremental(initial, newlyFetched, follow); } else if (initial) { // Si es carga inicial y no tenemos mensajes, mostrar una vista vacía const container = document.getElementById('chat-conversations'); @@ -2817,7 +2950,7 @@ } // Incremental rendering to avoid DOM replacement that interrupts media playback or scroll - renderMessagesIncremental(initial = false, prependCount = 0) { + renderMessagesIncremental(initial = false, prependCount = 0, follow = true) { const container = document.getElementById('chat-conversations'); if (!container) return; @@ -2835,12 +2968,30 @@ container.innerHTML = ''; } + // Snapshot scroll metrics to preserve user visual position when updating DOM + // prevScrollHeight: total height BEFORE we mutate the DOM + // prevScrollTop: scrollTop BEFORE update (so we can reapply offset) + const prevScrollHeight = container.scrollHeight; + const prevScrollTop = container.scrollTop; + // wasNearBottomBefore: were we following the bottom before update? + const wasNearBottomBefore = (prevScrollHeight - prevScrollTop - container.clientHeight) < 150; + // Map existing nodes const existing = new Map(); Array.from(container.querySelectorAll('[data-message-id]')).forEach(el => { existing.set(String(el.dataset.messageId), el); }); + // Compute maximum timestamp among existing DOM messages. This helps detect + // which incoming messages are strictly newer than what the user currently sees. + let maxExistingTs = 0; + existing.forEach(el => { + try { + const d = el.dataset && el.dataset.createdAt ? Date.parse(el.dataset.createdAt) : 0; + if (d && d > maxExistingTs) maxExistingTs = d; + } catch (e) { /* ignore parse errors */ } + }); + // helper: format date separators and compare same day const sameDay = (a,b) => { try { @@ -2954,6 +3105,8 @@ timeEl.textContent = short; timeEl.dataset.full = full; timeEl.dataset.short = short; + // preserve a machine-readable timestamp on the element so we can detect newer messages + try { if (existingEl) existingEl.dataset.createdAt = msg.created_at; } catch(e) {} if (!timeEl._hasToggleListener) { timeEl.addEventListener('click', (e) => { e.stopPropagation(); @@ -2991,6 +3144,21 @@ } else { // create new element try { + // If this message is strictly newer than anything already in the DOM and the user was + // not near the bottom (i.e., reading history), stage it instead of appending to avoid + // moving the user's viewport. + try { + const msgTs = msg.created_at ? Date.parse(msg.created_at) : 0; + const isNewerThanExisting = msgTs && (msgTs > maxExistingTs); + if (isNewerThanExisting && !wasNearBottomBefore && !this._userScrolling) { + this._stagedMessageIds.add(mid); + this._stagedCount = this._stagedMessageIds.size; + this.showNewMessagesIndicator(this._stagedCount); + // skip creation for now; message will be rendered when user clicks the indicator + continue; + } + } catch (e) { /* ignore staging errors */ } + // insert date separator if this message is the first of a new day const prev = (i>0) ? this.conversations[i-1] : null; if (!prev || !sameDay(prev.created_at, msg.created_at)) { @@ -3005,6 +3173,7 @@ const div = document.createElement('div'); div.className = 'message ' + (msg.direction || 'incoming'); div.dataset.messageId = mid; + div.dataset.createdAt = msg.created_at || ''; let replyHtml = ''; if (msg.reply_to_message_id) { @@ -3174,12 +3343,41 @@ // remove any remaining old elements that weren't updated existing.forEach((el) => el.remove()); - // If user was near bottom before update and not actively scrolling, keep it at bottom to follow conversation - const nearBottom = (container.scrollHeight - container.scrollTop - container.clientHeight) < 150; - const shouldScroll = initial ? true : (nearBottom && !this._userScrolling); + // After DOM changes, compute new heights and preserve user's visual position when appropriate + const newScrollHeight = container.scrollHeight; + const scrollDelta = newScrollHeight - prevScrollHeight; // positive when content grew - if (shouldScroll) { - this.scrollToBottom(); + if (initial) { + if (follow) { + // Initial load and follow requested: show latest messages + this.scrollToBottom(); + } else { + // Initial load but DO NOT follow: preserve visual offset like we do for non-initial updates + if (!wasNearBottomBefore && !this._userScrolling) { + const newTop = Math.max(0, prevScrollTop + scrollDelta); + container.scrollTop = newTop; + } else { + const nearBottomNow = (newScrollHeight - container.scrollTop - container.clientHeight) < 150; + if (nearBottomNow && !this._userScrolling) { + this.scrollToBottom(); + } + } + } + } else { + // If the user was reading history (not near bottom before) and is not actively scrolling, + // keep the viewport anchored to the same messages by adjusting scrollTop by the delta. + if (!wasNearBottomBefore && !this._userScrolling) { + // Keep the same visual offset (don't jump) + const newTop = Math.max(0, prevScrollTop + scrollDelta); + container.scrollTop = newTop; + } else { + // If we were near bottom and still not actively scrolling, follow new messages + const nearBottomNow = (newScrollHeight - container.scrollTop - container.clientHeight) < 150; + if (nearBottomNow && !this._userScrolling) { + this.scrollToBottom(); + } + // Otherwise (user actively scrolling) do nothing and let user's action control view + } } @@ -3298,7 +3496,7 @@ if ((this._pendingReloadAfterMedia || this._pendingReloadAfterScroll) && this.currentUserId) { this._pendingReloadAfterMedia = false; this._pendingReloadAfterScroll = false; - await this.loadMessages(this.currentUserId, true); + await this.loadMessages(this.currentUserId, true, false); } } catch (e) { console.warn('Failed to resume reload after recording', e); } }; @@ -3347,7 +3545,7 @@ if ((this._pendingReloadAfterMedia || this._pendingReloadAfterScroll) && this.currentUserId) { this._pendingReloadAfterMedia = false; this._pendingReloadAfterScroll = false; - this.loadMessages(this.currentUserId, true).catch(e => console.warn('Reload after stopRecording failed', e)); + this.loadMessages(this.currentUserId, true, false).catch(e => console.warn('Reload after stopRecording failed', e)); } } catch (e) { console.warn('stopRecording resume failed', e); } }; @@ -3378,7 +3576,7 @@ if ((this._pendingReloadAfterMedia || this._pendingReloadAfterScroll) && this.currentUserId) { this._pendingReloadAfterMedia = false; this._pendingReloadAfterScroll = false; - this.loadMessages(this.currentUserId, true).catch(e => console.warn('Reload after cancelRecording failed', e)); + this.loadMessages(this.currentUserId, true, false).catch(e => console.warn('Reload after cancelRecording failed', e)); } } catch (e) { console.warn('cancelRecording resume failed', e); } }; @@ -3386,13 +3584,83 @@ return true; } - // --- New messages indicator helpers (removed) --- +// --- New messages indicator helpers --- showNewMessagesIndicator(count) { - // No-op: indicator removed — full reload & scroll-to-bottom now used + try { + if (this._applyingStaged) return; // avoid toggling while applying + const container = document.getElementById('chat-area') || document.body; + let el = document.getElementById('new-messages-indicator'); + if (!el) { + el = document.createElement('div'); + el.id = 'new-messages-indicator'; + el.style.position = 'absolute'; + el.style.left = '50%'; + el.style.transform = 'translateX(-50%)'; + el.style.bottom = '84px'; + el.style.zIndex = '1500'; + el.className = 'btn btn-primary'; + el.style.padding = '8px 14px'; + el.style.borderRadius = '20px'; + el.style.boxShadow = '0 6px 18px rgba(2,6,23,0.12)'; + el.style.cursor = 'pointer'; + el.onclick = () => this.applyStagedMessages(); + // subtle fade-in + el.style.opacity = '0'; + el.style.transition = 'opacity 220ms ease, transform 220ms ease'; + container.appendChild(el); + setTimeout(() => { try { el.style.opacity = '1'; el.style.transform = 'translateX(-50%) translateY(-6px)'; } catch(e) {} }, 20); + } + const c = (typeof count === 'number') ? count : (this._stagedMessages ? this._stagedMessages.length : 0); + el.textContent = (c && c > 1) ? `Nuevos mensajes (${c})` : 'Nuevo mensaje'; + el.style.display = 'inline-block'; + } catch (e) { console.warn('showNewMessagesIndicator failed', e); } } hideNewMessagesIndicator() { - // No-op + try { + // remove any instances of the indicator (handle duplicates) + const els = document.querySelectorAll('#new-messages-indicator'); + els.forEach(el => { try { if (el && el.parentNode) el.parentNode.removeChild(el); } catch(e){} }); + } catch (e) { /* ignore */ } + } + + applyStagedMessages() { + try { + // If nothing to apply, ensure indicator is gone + if (!this._stagedMessages || this._stagedMessages.length === 0) { + this.hideNewMessagesIndicator(); + return; + } + + // Mark as applying so we don't re-show indicator mid-apply + this._applyingStaged = true; + + // Immediately hide indicator to provide instant feedback + this.hideNewMessagesIndicator(); + + // Append staged messages to conversation model + const toApplyCount = this._stagedMessages.length; + console.debug('applyStagedMessages: applying', toApplyCount, 'messages'); + this._stagedMessages.forEach(m => { this.conversations.push(m); }); + + // clear staged storage + this._stagedMessages = []; + if (this._stagedMessageIds) this._stagedMessageIds.clear(); + this._stagedCount = 0; + + // Render newly added messages and follow to bottom + this.renderMessagesIncremental(false, 0, true); + this.scrollToBottom(); + + // Update latest timestamp + try { const last = this.conversations[this.conversations.length - 1]; if (last && last.created_at) this._latestMessage = last.created_at; } catch(e){} + + } catch (e) { + console.warn('applyStagedMessages failed', e); + } finally { + // allow indicator to show again for future arrivals after short delay + setTimeout(() => { this._applyingStaged = false; }, 250); + } } getStatusIcon(status) { diff --git a/logs/system.log b/logs/system.log index 7158b31..b3915ab 100644 --- a/logs/system.log +++ b/logs/system.log @@ -73,3 +73,4 @@ Stack trace: [2026-01-25 22:46:00] [INFO] Advisor solicited for 573194724531 until 2026-01-25 22:48:58 [2026-01-25 22:48:41] [INFO] Bot paused for 3 minutes for 573194724531 [2026-01-25 22:48:44] [INFO] Advisor solicited for 573194724531 until 2026-01-25 22:51:42 +[2026-01-27 00:01:19] [INFO] Cleared advisor_requested for user 3180 after outgoing message by operator diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..dec063b --- /dev/null +++ b/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "WhatsApp Bot Manager", + "short_name": "WA Bot", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#10b981", + "icons": [ + { + "src": "/assets/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + } + ] +} diff --git a/service-worker.js b/service-worker.js new file mode 100644 index 0000000..0e26397 --- /dev/null +++ b/service-worker.js @@ -0,0 +1,11 @@ +// Minimal service worker stub to avoid 404s during development. +self.addEventListener('install', (event) => { + // activate immediately + self.skipWaiting(); +}); +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); +self.addEventListener('fetch', (event) => { + // No-op: default network behaviour +});