up
This commit is contained in:
+39
-17
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
Reference in New Issue
Block a user