414 lines
17 KiB
JavaScript
Executable File
414 lines
17 KiB
JavaScript
Executable File
/* ── Socket ──────────────────────────────────────────────────────────────── */
|
|
const socket = io({
|
|
path: window.location.pathname.replace(/\/$/, '') + '/socket.io'
|
|
});
|
|
|
|
/* ── State ───────────────────────────────────────────────────────────────── */
|
|
let state = {
|
|
session: 'disconnected',
|
|
conversations: [],
|
|
selectedChatId: null,
|
|
stats: {},
|
|
sending: false,
|
|
aiLoading: false,
|
|
demoMode: false
|
|
};
|
|
|
|
/* ── DOM Refs ─────────────────────────────────────────────────────────────── */
|
|
const $ = id => document.getElementById(id);
|
|
const inboxList = $('inbox-list');
|
|
const convPlaceholder= $('conv-placeholder');
|
|
const convView = $('conv-view');
|
|
const messageList = $('message-list');
|
|
const composeInput = $('compose-input');
|
|
const btnSend = $('btn-send');
|
|
const btnAiDraft = $('btn-ai-draft');
|
|
const aiDraftBar = $('ai-draft-bar');
|
|
const aiDraftStatus = $('ai-draft-status');
|
|
const charCount = $('char-count');
|
|
const opsPanel = $('ops-panel');
|
|
const demoBanner = $('demo-banner');
|
|
|
|
/* ── Utilities ────────────────────────────────────────────────────────────── */
|
|
function formatTime(iso) {
|
|
const d = new Date(iso);
|
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
function formatDate(iso) {
|
|
const d = new Date(iso);
|
|
const today = new Date();
|
|
const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1);
|
|
if (d.toDateString() === today.toDateString()) return 'Today';
|
|
if (d.toDateString() === yesterday.toDateString()) return 'Yesterday';
|
|
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
}
|
|
function formatRelative(iso) {
|
|
const diff = Date.now() - new Date(iso).getTime();
|
|
if (diff < 60000) return 'just now';
|
|
if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`;
|
|
if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`;
|
|
return new Date(iso).toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
}
|
|
function toast(msg, type = 'green') {
|
|
const el = document.createElement('div');
|
|
el.className = `toast ${type}`;
|
|
el.innerHTML = `<span class="toast-dot"></span><span>${msg}</span>`;
|
|
$('toasts').appendChild(el);
|
|
setTimeout(() => el.remove(), 3200);
|
|
}
|
|
|
|
/* ── Session Badge Helper ────────────────────────────────────────────────── */
|
|
const STATE_BADGE = {
|
|
connected: { cls: 'status-connected', text: 'Connected' },
|
|
awaiting_scan:{ cls: 'status-awaiting', text: 'Awaiting Scan' },
|
|
connecting: { cls: 'status-reconnecting', text: 'Connecting' },
|
|
reconnecting: { cls: 'status-reconnecting', text: 'Reconnecting' },
|
|
degraded: { cls: 'status-degraded', text: 'Degraded' },
|
|
disconnected: { cls: 'status-disconnected', text: 'Disconnected' }
|
|
};
|
|
function setBadge(el, sessionStr) {
|
|
const info = STATE_BADGE[sessionStr] || STATE_BADGE.disconnected;
|
|
el.className = `status-badge ${info.cls}`;
|
|
el.innerHTML = `<span class="dot"></span>${info.text}`;
|
|
}
|
|
|
|
/* ── Screen Router ────────────────────────────────────────────────────────── */
|
|
function route() {}
|
|
|
|
/* ── Stats Update ─────────────────────────────────────────────────────────── */
|
|
function updateStats(stats) {
|
|
state.stats = stats;
|
|
if (!stats) return;
|
|
|
|
$('ops-inbound-depth').textContent = stats.queueDepth ?? 0;
|
|
$('ops-outbound-depth').textContent = stats.outboundDepth ?? 0;
|
|
$('ops-send-success').textContent = stats.sendSuccess ?? 0;
|
|
$('ops-send-failed').textContent = stats.sendFailed ?? 0;
|
|
$('ops-reconnects').textContent = stats.reconnectCount ?? 0;
|
|
$('ops-heartbeat').textContent = stats.lastHeartbeat
|
|
? formatRelative(stats.lastHeartbeat) : '—';
|
|
|
|
const workerBusy = stats.workerStatus === 'busy';
|
|
const workerBadge = $('ops-worker-badge');
|
|
workerBadge.className = `status-badge ${workerBusy ? 'status-busy' : 'status-idle'}`;
|
|
workerBadge.innerHTML = `<span class="dot"></span>${workerBusy ? 'Processing' : 'Idle'}`;
|
|
}
|
|
|
|
/* ── Inbox Render ─────────────────────────────────────────────────────────── */
|
|
function renderInbox(conversations) {
|
|
state.conversations = conversations;
|
|
const q = $('inbox-search').value.toLowerCase();
|
|
const filtered = q
|
|
? conversations.filter(c => c.contact.toLowerCase().includes(q) || c.lastPreview?.toLowerCase().includes(q))
|
|
: conversations;
|
|
|
|
const totalUnread = conversations.reduce((s, c) => s + (c.unreadCount || 0), 0);
|
|
const badge = $('total-unread-badge');
|
|
if (totalUnread > 0) {
|
|
badge.textContent = totalUnread;
|
|
badge.classList.remove('hidden');
|
|
} else {
|
|
badge.classList.add('hidden');
|
|
}
|
|
|
|
if (!filtered.length) {
|
|
inboxList.innerHTML = '<div class="inbox-empty">No conversations</div>';
|
|
return;
|
|
}
|
|
|
|
inboxList.innerHTML = filtered.map(conv => `
|
|
<div class="inbox-item ${conv.id === state.selectedChatId ? 'active' : ''}"
|
|
data-id="${conv.id}" onclick="selectChat('${conv.id}')">
|
|
<div class="avatar">${conv.avatar || conv.contact.charAt(0).toUpperCase()}</div>
|
|
<div class="inbox-info">
|
|
<div class="inbox-row1">
|
|
<span class="inbox-name">${conv.contact}</span>
|
|
<span class="inbox-time">${conv.lastActivity ? formatRelative(conv.lastActivity) : ''}</span>
|
|
</div>
|
|
<div class="inbox-row2">
|
|
<span class="inbox-preview">${escHtml(conv.lastPreview || '')}</span>
|
|
${conv.unreadCount > 0 ? `<span class="inbox-unread">${conv.unreadCount > 9 ? '9+' : conv.unreadCount}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
/* ── Select Chat ──────────────────────────────────────────────────────────── */
|
|
async function selectChat(chatId) {
|
|
state.selectedChatId = chatId;
|
|
renderInbox(state.conversations);
|
|
convPlaceholder.classList.add('hidden');
|
|
convView.classList.remove('hidden');
|
|
|
|
const conv = state.conversations.find(c => c.id === chatId);
|
|
if (!conv) return;
|
|
|
|
$('conv-avatar').textContent = conv.avatar || conv.contact.charAt(0).toUpperCase();
|
|
$('conv-name').textContent = conv.contact;
|
|
$('conv-phone').textContent = conv.phone?.replace('@c.us', '').replace(/(\d{2})(\d{5})(\d{5})/, '+$1 $2 $3');
|
|
|
|
renderMessages(conv.messages);
|
|
|
|
// Mark read
|
|
fetch(`/api/mark-read/${encodeURIComponent(chatId)}`, { method: 'POST' });
|
|
if (conv.unreadCount > 0) {
|
|
conv.unreadCount = 0;
|
|
renderInbox(state.conversations);
|
|
}
|
|
|
|
composeInput.focus();
|
|
}
|
|
|
|
/* ── Message Render ───────────────────────────────────────────────────────── */
|
|
function renderMessages(messages) {
|
|
if (!messages || !messages.length) {
|
|
messageList.innerHTML = '<div style="text-align:center;color:var(--text-dim);padding:2rem;font-size:.85rem">No messages yet</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
let lastDate = '';
|
|
messages.forEach(msg => {
|
|
const d = formatDate(msg.timestamp);
|
|
if (d !== lastDate) {
|
|
html += `<div class="date-divider">${d}</div>`;
|
|
lastDate = d;
|
|
}
|
|
const fromMe = msg.fromMe;
|
|
html += `
|
|
<div class="msg-wrap ${fromMe ? 'from-me' : 'from-them'}">
|
|
<div class="bubble">
|
|
${escHtml(msg.body)}
|
|
<div class="bubble-meta">
|
|
<span>${formatTime(msg.timestamp)}</span>
|
|
${fromMe ? `<span class="tick sent">${msg.state === 'sent' ? '✓✓' : '✓'}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
messageList.innerHTML = html;
|
|
messageList.scrollTop = messageList.scrollHeight;
|
|
}
|
|
|
|
function escHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
/* ── Compose Handlers ─────────────────────────────────────────────────────── */
|
|
composeInput.addEventListener('input', () => {
|
|
composeInput.style.height = 'auto';
|
|
composeInput.style.height = Math.min(composeInput.scrollHeight, 140) + 'px';
|
|
const len = composeInput.value.length;
|
|
charCount.textContent = len;
|
|
btnSend.disabled = len === 0 || state.sending;
|
|
});
|
|
|
|
composeInput.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
|
|
});
|
|
|
|
btnSend.addEventListener('click', doSend);
|
|
|
|
async function doSend() {
|
|
const body = composeInput.value.trim();
|
|
if (!body || !state.selectedChatId || state.sending) return;
|
|
|
|
state.sending = true;
|
|
btnSend.disabled = true;
|
|
btnSend.innerHTML = '<div class="loading-spinner small"></div>';
|
|
|
|
const idempotencyKey = `${state.selectedChatId}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
|
|
try {
|
|
const res = await fetch(window.location.pathname + 'api/send', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ chatId: state.selectedChatId, body, idempotencyKey })
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.queued) {
|
|
// Optimistic UI — add message immediately
|
|
const conv = state.conversations.find(c => c.id === state.selectedChatId);
|
|
if (conv) {
|
|
const msg = { id: idempotencyKey, body, fromMe: true, timestamp: new Date().toISOString(), state: 'sending' };
|
|
conv.messages = conv.messages || [];
|
|
conv.messages.push(msg);
|
|
conv.lastPreview = body;
|
|
conv.lastActivity = msg.timestamp;
|
|
renderMessages(conv.messages);
|
|
renderInbox(state.conversations);
|
|
}
|
|
composeInput.value = '';
|
|
composeInput.style.height = 'auto';
|
|
charCount.textContent = '0';
|
|
aiDraftBar.classList.add('hidden');
|
|
toast('Message queued for sending');
|
|
} else if (data.duplicate) {
|
|
toast('Duplicate send prevented', 'purple');
|
|
}
|
|
} catch {
|
|
toast('Failed to queue message', 'red');
|
|
}
|
|
|
|
state.sending = false;
|
|
btnSend.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> Send';
|
|
btnSend.disabled = composeInput.value.length === 0;
|
|
}
|
|
|
|
/* ── AI Draft ─────────────────────────────────────────────────────────────── */
|
|
btnAiDraft.addEventListener('click', doAiDraft);
|
|
|
|
async function doAiDraft() {
|
|
if (!state.selectedChatId || state.aiLoading) return;
|
|
|
|
state.aiLoading = true;
|
|
btnAiDraft.disabled = true;
|
|
aiDraftBar.classList.remove('hidden');
|
|
aiDraftStatus.textContent = 'Generating draft…';
|
|
$('ops-ai-badge').className = 'status-badge status-busy';
|
|
$('ops-ai-badge').innerHTML = '<span class="dot"></span>Generating';
|
|
|
|
try {
|
|
const res = await fetch(window.location.pathname + 'api/ai-draft', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ chatId: state.selectedChatId })
|
|
});
|
|
const data = await res.json();
|
|
|
|
if (data.draft) {
|
|
composeInput.value = data.draft;
|
|
composeInput.style.height = 'auto';
|
|
composeInput.style.height = Math.min(composeInput.scrollHeight, 140) + 'px';
|
|
charCount.textContent = data.draft.length;
|
|
btnSend.disabled = false;
|
|
aiDraftStatus.textContent = 'Draft ready — review before sending';
|
|
composeInput.focus();
|
|
toast('AI draft generated', 'purple');
|
|
} else {
|
|
aiDraftStatus.textContent = data.error || 'Failed to generate draft';
|
|
toast(data.error || 'AI draft failed', 'red');
|
|
}
|
|
} catch {
|
|
aiDraftStatus.textContent = 'AI service unavailable';
|
|
toast('AI service unavailable', 'red');
|
|
}
|
|
|
|
state.aiLoading = false;
|
|
btnAiDraft.disabled = false;
|
|
$('ops-ai-badge').className = 'status-badge status-idle';
|
|
$('ops-ai-badge').innerHTML = '<span class="dot"></span>Ready';
|
|
}
|
|
|
|
$('btn-dismiss-draft').addEventListener('click', () => {
|
|
aiDraftBar.classList.add('hidden');
|
|
});
|
|
|
|
/* ── Socket Events ────────────────────────────────────────────────────────── */
|
|
socket.on('session_state', ({ state: s, qrDataUrl, stats }) => {
|
|
setBadge($('sidebar-session-badge'), s);
|
|
setBadge($('ops-session-badge'), s);
|
|
$('sidebar-session-text').textContent = STATE_BADGE[s]?.text || s;
|
|
updateStats(stats);
|
|
route(s, qrDataUrl);
|
|
|
|
if (s === 'connected' && state.session !== 'connected') {
|
|
toast('WhatsApp session connected');
|
|
}
|
|
state.session = s;
|
|
});
|
|
|
|
socket.on('inbox_update', (inbox) => {
|
|
renderInbox(inbox);
|
|
// If current chat got new messages, refresh view
|
|
if (state.selectedChatId) {
|
|
const conv = inbox.find(c => c.id === state.selectedChatId);
|
|
if (conv) renderMessages(conv.messages);
|
|
}
|
|
});
|
|
|
|
socket.on('new_message', ({ chatId, conversation }) => {
|
|
// Update conversation in local state
|
|
const idx = state.conversations.findIndex(c => c.id === chatId);
|
|
if (idx >= 0) {
|
|
state.conversations[idx] = conversation;
|
|
} else {
|
|
state.conversations.unshift(conversation);
|
|
}
|
|
|
|
if (chatId === state.selectedChatId) {
|
|
renderMessages(conversation.messages);
|
|
}
|
|
|
|
toast(`New message from ${conversation.contact}`, 'green');
|
|
});
|
|
|
|
socket.on('message_state', (job) => {
|
|
// Update optimistic message state
|
|
const conv = state.conversations.find(c => c.id === job.chatId);
|
|
if (!conv) return;
|
|
const msg = conv.messages.find(m => m.id === job.idempotencyKey);
|
|
if (msg) {
|
|
msg.state = job.state;
|
|
if (job.state === 'sent') msg.timestamp = job.sentAt || msg.timestamp;
|
|
if (job.chatId === state.selectedChatId) renderMessages(conv.messages);
|
|
}
|
|
if (job.state === 'failed') toast(`Message failed: ${job.error}`, 'red');
|
|
});
|
|
|
|
socket.on('stats_update', updateStats);
|
|
|
|
/* ── Inbox Search ─────────────────────────────────────────────────────────── */
|
|
$('inbox-search').addEventListener('input', () => renderInbox(state.conversations));
|
|
|
|
/* ── Ops Panel Toggle ─────────────────────────────────────────────────────── */
|
|
$('btn-ops-toggle').addEventListener('click', () => {
|
|
opsPanel.classList.toggle('open');
|
|
});
|
|
|
|
/* ── Auto-resize compose on load ─────────────────────────────────────────── */
|
|
window.addEventListener('resize', () => {
|
|
if (composeInput.value) {
|
|
composeInput.style.height = 'auto';
|
|
composeInput.style.height = Math.min(composeInput.scrollHeight, 140) + 'px';
|
|
}
|
|
});
|
|
|
|
/* ── Initial Data Load ────────────────────────────────────────────────────── */
|
|
(async () => {
|
|
try {
|
|
const [sessionRes, inboxRes] = await Promise.all([
|
|
fetch(window.location.pathname + 'api/session'),
|
|
fetch(window.location.pathname + 'api/inbox')
|
|
]);
|
|
const sessionData = await sessionRes.json();
|
|
const inbox = await inboxRes.json();
|
|
|
|
// Detect demo mode
|
|
if (sessionData.demoMode || (sessionData.state === 'connected' && inbox.length > 0)) {
|
|
const firstConv = inbox[0];
|
|
if (firstConv?.phone?.includes('@c.us')) {
|
|
demoBanner.classList.remove('hidden');
|
|
state.demoMode = true;
|
|
}
|
|
}
|
|
|
|
updateStats(sessionData.stats);
|
|
setBadge($('sidebar-session-badge'), sessionData.state);
|
|
setBadge($('ops-session-badge'), sessionData.state);
|
|
state.session = sessionData.state;
|
|
|
|
renderInbox(inbox);
|
|
route(sessionData.state, sessionData.qrDataUrl);
|
|
} catch (err) {
|
|
console.error('Init error', err);
|
|
route('disconnected', null);
|
|
}
|
|
})();
|