This commit is contained in:
Lizandro Guarnizo
2026-01-27 00:12:04 -05:00
parent a086e4a19a
commit 267805d5cd
6 changed files with 404 additions and 54 deletions
+39 -17
View File
@@ -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;
+33
View File
@@ -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
+305 -37
View File
@@ -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) {
+1
View File
@@ -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
+15
View File
@@ -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"
}
]
}
+11
View File
@@ -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
});