From 3a1ed0903cd2d1121c82db48caf48d5650aced52 Mon Sep 17 00:00:00 2001 From: Adrian Victor Date: Thu, 16 Jul 2026 15:26:42 -0300 Subject: [PATCH] Save chats, messages and media to IndexedDB --- index.html | 5 +- app.js => js/app.js | 87 +++++--------- config.js => js/config.js | 0 js/db.js | 204 ++++++++++++++++++++++++++++++++ js/storage.js | 108 +++++++++++++++++ ui.js => js/ui.js | 11 +- utils.js => js/utils.js | 0 waha.js => js/waha.js | 0 websocket.js => js/websocket.js | 0 9 files changed, 348 insertions(+), 67 deletions(-) rename app.js => js/app.js (82%) rename config.js => js/config.js (100%) create mode 100644 js/db.js create mode 100644 js/storage.js rename ui.js => js/ui.js (97%) rename utils.js => js/utils.js (100%) rename waha.js => js/waha.js (100%) rename websocket.js => js/websocket.js (100%) diff --git a/index.html b/index.html index 02bbde6..ebe1c84 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + Pandora @@ -10,7 +10,6 @@ - + diff --git a/app.js b/js/app.js similarity index 82% rename from app.js rename to js/app.js index c18a07b..5e39efa 100644 --- a/app.js +++ b/js/app.js @@ -3,20 +3,31 @@ import { waha } from "./waha.js"; import { ui, elements } from "./ui.js"; import { websocket } from "./websocket.js"; import { compensateMessageOrdering, formatTime } from "./utils.js"; +import { fetchChats, getAppUser, getChatMessages, getChats } from "./storage.js"; +import { upsertMessages } from "./db.js"; -let chatsState = []; let activeChatState = null; let userInfo; const messageTone = new Audio("./message.ogg"); -// Initialize app when DOM is ready document.addEventListener('DOMContentLoaded', async () => { setupEventListeners(); try { - userInfo = await waha.getMyInfo(); - fetchChats(); + elements.loggedUserName.textContent = await getAppUser().pushName; + loadChats(); checkWahaStatus(); initWebSocket(); + } finally { + elements.chatsLoader.classList.add('hidden'); + } +}); + +function loadChats() { + try { + elements.chatsLoader.classList.remove('hidden'); + fetchChats().then(() => { + ui.renderChatList(getChats(), activeChatState, selectChat); + }); } catch (error) { console.error('Failed to load chats:', error); elements.chatList.innerHTML = ` @@ -31,7 +42,7 @@ document.addEventListener('DOMContentLoaded', async () => { } finally { elements.chatsLoader.classList.add('hidden'); } -}); +} function setupEventListeners() { document.addEventListener('keydown', (e) => { @@ -43,16 +54,15 @@ function setupEventListeners() { elements.refreshChatsBtn.addEventListener('click', () => { elements.refreshChatsBtn.classList.add('spinning'); - fetchChats().finally(() => { - setTimeout(() => { - elements.refreshChatsBtn.classList.remove('spinning'); - }, 600); - }); + loadChats() + setTimeout(() => { + elements.refreshChatsBtn.classList.remove('spinning'); + }, 600); }); elements.chatSearch.addEventListener('input', (e) => { const query = e.target.value.toLowerCase(); - const filtered = chatsState.filter(chat => + const filtered = getChats().filter(chat => chat.name.toLowerCase().includes(query) ); ui.renderChatList(filtered, activeChatState, selectChat); @@ -99,6 +109,7 @@ function initWebSocket() { const ev = data.event; if (ev === 'message' || ev === 'message.any' || ev === 'message.ack') { handleIncomingMessage(data.payload); + upsertMessages([data.payload]); } }); } @@ -115,7 +126,7 @@ function handleIncomingMessage(msg) { if (!msg) return; // console.log('[WS] handleIncomingMessage payload:', msg); - + ui.updateChatInChatList(msg); const rawChatId = msg.chatId || msg.from || (msg.chat && msg.chat.id); @@ -139,48 +150,15 @@ function handleIncomingMessage(msg) { } } - const chatIndex = chatsState.findIndex(c => c.id === msgChatId); + const chatIndex = getChats().findIndex(c => c.id === msgChatId); if (chatIndex !== -1) { - const chat = chatsState[chatIndex]; + const chat = getChats()[chatIndex]; chat.lastMessage = msg.body || msg.text || 'Media message'; chat.timestamp = msg.timestamp ? (msg.timestamp * 1000) : Date.now(); if (!msg.fromMe && (!activeChatState || activeChatState.id !== msgChatId)) { chat.unreadCount = (chat.unreadCount || 0) + 1; - } - - chatsState.sort((a, b) => { - const tA = new Date(a.timestamp).getTime(); - const tB = new Date(b.timestamp).getTime(); - return tB - tA; - }); - - // ui.renderChatList(chatsState, activeChatState, selectChat); - } else { - // fetchChats(); - } -} - -async function fetchChats() { - try { - elements.loggedUserName.textContent = userInfo.pushName; - // elements.userIcon.innerText = userInfo.pushName[0]; - elements.chatsLoader.classList.remove('hidden'); - chatsState = await waha.getChats(); - ui.renderChatList(chatsState, activeChatState, selectChat); - } catch (error) { - console.error('Failed to load chats:', error); - elements.chatList.innerHTML = ` -
  • -

    Connection to WAHA failed.

    -

    - Ensure WAHA server is running and CORS is enabled, or click Settings to configure. -

    -

    ${error.message}

    -
  • - `; - } finally { - elements.chatsLoader.classList.add('hidden'); + } } } @@ -213,9 +191,9 @@ async function selectChat(chat) { } try { - const rawMessages = await waha.getChatMessages(chat.id); + const rawMessages = await getChatMessages(chat.id); const processedMessages = compensateMessageOrdering(rawMessages); - ui.renderMessages(processedMessages, chat.name, userInfo.id, chat.id); + ui.renderMessages(processedMessages, chat.name, getAppUser().id, chat.id); } catch (error) { console.error('Failed to load messages:', error); elements.messagesContainer.innerHTML = '
    Error loading messages
    '; @@ -276,13 +254,6 @@ async function sendMessage() { activeChatState.lastMessage = text; activeChatState.timestamp = new Date(); - - chatsState.sort((a, b) => { - const tA = new Date(a.timestamp).getTime(); - const tB = new Date(b.timestamp).getTime(); - return tB - tA; - }); - // ui.renderChatList(chatsState, activeChatState, selectChat); } catch (error) { console.error('Failed to send message:', error); const tempBubble = document.getElementById(tempMsg.id); @@ -337,7 +308,7 @@ function saveSettings() { elements.inputApiKey.value ); ui.toggleModal(false); - fetchChats(); + loadChats(); checkWahaStatus(); initWebSocket(); } diff --git a/config.js b/js/config.js similarity index 100% rename from config.js rename to js/config.js diff --git a/js/db.js b/js/db.js new file mode 100644 index 0000000..de62c76 --- /dev/null +++ b/js/db.js @@ -0,0 +1,204 @@ +const DB_NAME = "pandora"; +const DB_VERSION = 6; +let dbPromise = null; + +function normalizeId(raw) { + if (!raw) return null; + if (typeof raw === 'object') { + return raw._serialized || raw.user || JSON.stringify(raw); + } + return raw; +} + +function openDb() { + if (dbPromise) return dbPromise; + + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + + req.onupgradeneeded = (e) => { + const db = req.result; + const tx = req.transaction; + + if (!db.objectStoreNames.contains("chats")) { + const store = db.createObjectStore("chats", { keyPath: "id" }); + store.createIndex("timestamp", "timestamp", { unique: false }); + } + + let msgStore; + if (!db.objectStoreNames.contains("messages")) { + msgStore = db.createObjectStore("messages", { keyPath: "id" }); + msgStore.createIndex("from", "from", { unique: false }); + msgStore.createIndex("fingerprint", ["from", "timestamp"], { unique: false }); + } else { + msgStore = tx.objectStore("messages"); + } + + if (!msgStore.indexNames.contains("chatId_timestamp")) { + msgStore.createIndex("chatId_timestamp", ["chatId", "timestamp"], { unique: false }); + } + + if (db.objectStoreNames.contains("messages")) { + msgStore.openCursor().onsuccess = (event) => { + const cursor = event.target.result; + if (cursor) { + const m = cursor.value; + const from = normalizeId(m.from); + const to = normalizeId(m.to); + const chatId = normalizeId(m.chatId) || (m.fromMe ? to : from); + if (!m.chatId && chatId) { + m.chatId = chatId; + cursor.update(m); + } + cursor.continue(); + } + }; + } + + if (!db.objectStoreNames.contains("media")) { + db.createObjectStore("media", { keyPath: "reqId" }); + } + }; + + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + + return dbPromise; +} + +export async function upsertChats(chats) { + const db = await openDb(); + + return new Promise((resolve, reject) => { + const tx = db.transaction("chats", "readwrite"); + const store = tx.objectStore("chats"); + + for (const c of chats) { + store.put({ + id: c.id, + name: c.name, + lastMessage: c.lastMessage, + timestamp: c.timestamp, + unreadCount: c.unreadCount ?? 0 + }); + } + + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +export async function loadChatsSorted() { + const db = await openDb(); + + return new Promise((resolve, reject) => { + const tx = db.transaction("chats", "readonly"); + const store = tx.objectStore("chats"); + const idx = store.index("timestamp"); + + const result = []; + idx.openCursor(null, "prev").onsuccess = (e) => { + const cursor = e.target.result; + if (cursor) { + result.push(cursor.value); + cursor.continue(); + } else { + resolve(result); + } + }; + + tx.onerror = () => reject(tx.error); + }); +} + +function mapMessage(m) { + const from = normalizeId(m.from); + const to = normalizeId(m.to); + const chatId = normalizeId(m.chatId) || (m.fromMe ? to : from); + + return { + _data: m._data, + id: m.id, + timestamp: m.timestamp, + body: m.body, + from: from, + fromMe: m.fromMe, + ack: m.ack, + hasMedia: m.hasMedia, + media: m.media, + chatId: chatId, + } +} + +export async function upsertMessages(messages) { + const db = await openDb(); + + return new Promise((resolve, reject) => { + const tx = db.transaction("messages", "readwrite"); + const store = tx.objectStore("messages"); + + for (const m of messages) { + store.put(mapMessage(m)); + } + + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +export async function loadLatestMessages(chatId, limit = 50) { + const db = await openDb(); + + return new Promise((resolve, reject) => { + const tx = db.transaction("messages", "readonly"); + const store = tx.objectStore("messages"); + const idx = store.index("chatId_timestamp"); + + const out = []; + + const range = IDBKeyRange.bound([chatId, -Infinity], [chatId, Infinity]); + + idx.openCursor(range, "prev").onsuccess = (e) => { + const cursor = e.target.result; + if (!cursor) return resolve(out); + + out.push(cursor.value); + if (out.length >= limit) resolve(out); + else cursor.continue(); + }; + + tx.onerror = () => reject(tx.error); + }); +} + +export async function upsertMedia(reqId, blob, filename) { + const db = await openDb(); + + return new Promise((resolve, reject) => { + const tx = db.transaction("media", "readwrite"); + const store = tx.objectStore("media"); + + store.put({ + reqId: reqId, + blob: blob, + filename: filename + }); + + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +export async function loadMedia(reqId) { + const db = await openDb(); + + return new Promise((resolve, reject) => { + const tx = db.transaction("media", "readonly"); + const store = tx.objectStore("media"); + const req = store.get(reqId); + + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} \ No newline at end of file diff --git a/js/storage.js b/js/storage.js new file mode 100644 index 0000000..745567e --- /dev/null +++ b/js/storage.js @@ -0,0 +1,108 @@ +import { loadChatsSorted, loadLatestMessages, loadMedia, upsertChats, upsertMedia, upsertMessages } from "./db.js"; +import { waha } from "./waha.js"; + +let user; +let chats = []; + +export async function fetchChats() { + try { + await getRemoteChats() + } catch (error) { + + } + + chats = await loadChatsSorted(); +} + +export async function getRemoteChats() { + const u = await waha.getChats(); + + const mapped = u.map(chat => ({ + id: chat.id, + name: chat.name, + lastMessage: chat.lastMessage, + timestamp: chat.timestamp, + unreadCount: chat.unreadCount ?? 0 + })); + + await upsertChats(mapped); +} + +export function getUsers() { + return chats.filter(c => c.id.endsWith("@c.us")); +} + +export function getGroups() { + return chats.filter(c => c.id.endsWith("@g.us")); +} + +export function getUser(number) { + return users.find(user => user.id === number); +} + +export function getChats() { + return chats; +} + +export async function getAppUser() { + if (user) { + return user; + } else { + try { + return await waha.getMyInfo(); + } catch (error) { + console.error(error); + return { + pushName: "Unknown", + id: "Unknown" + } + } + } +} + +export async function getMessage(chatId, msgId, downloadMedia) { + try { + const newMessage = await waha.getSingleChatMessage(chatId, msgId, downloadMedia); + upsertMessages([newMessage]); + return newMessage; + } catch (error) { + return { + id: `${Date.now()}-temp`, + body: "You're offline", + from: "system", + timestamp: new Date().toISOString() + }; + } +} + +export async function getMedia(reqId) { + try { + const media = await waha.downloadMedia(reqId); + upsertMedia(reqId, media.blob, media.filename); + return media; + } catch (error) { + const cached = await loadMedia(reqId); + if (cached) { + return cached; + } + throw error; + } +} + +export async function getChatMessages(chatId) { + try { + const newMessages = await waha.getChatMessages(chatId); + upsertMessages(newMessages); + return newMessages; + } catch (error) { + return await loadLatestMessages(chatId); + } +} + +export async function getChatPicture(chatId) { + try { + return await waha.getChatPicture(chatId); + } catch (error) { + return ""; + } +} \ No newline at end of file diff --git a/ui.js b/js/ui.js similarity index 97% rename from ui.js rename to js/ui.js index a530438..c0c5ff5 100644 --- a/ui.js +++ b/js/ui.js @@ -1,6 +1,7 @@ import { formatTime } from "./utils.js"; import { waha } from "./waha.js"; import { config } from "./config.js"; +import { getChatPicture, getMessage, getMedia } from "./storage.js"; // DOM Selector Cache export const elements = { @@ -92,12 +93,11 @@ export const ui = { } for (const chat of chats) { - console.log(chat); const li = document.createElement('li'); li.className = `chat-item ${activeChat && activeChat.id === chat.id ? 'active' : ''}`; li.dataset.id = chat.id; - const picture = await waha.getChatPicture(chat.id); + const picture = await getChatPicture(chat.id); const initials = chat.name ? chat.name.substring(0, 1).toUpperCase() : '?'; const hasUnread = chat.unreadCount && chat.unreadCount > 0; const timeStr = formatTime(chat.timestamp || new Date()); @@ -141,7 +141,6 @@ export const ui = { this.appendSingleMessage(msg, activeChatName, userID, chatId); } - lucide.createIcons(); this.scrollToBottom(); }, @@ -171,6 +170,7 @@ export const ui = { }, generateMessage(msg, activeChatName, userID, chatId, isLocal = false) { + console.log(msg); const isOutgoing = msg.fromMe || msg.sender === 'me'; function getPrevMessageElem() { @@ -238,12 +238,11 @@ export const ui = { const clickListener = async (e) => { a.removeEventListener('click', clickListener); a.innerText = `[Downloading]`; - const mediaMsg = msg.media ? msg : await waha.getSingleChatMessage(chatId, msg.id, true); - console.log(mediaMsg.media.url); + const mediaMsg = msg.media ? msg : await getMessage(chatId, msg.id, true); const url = new URL(mediaMsg.media.url); const reqID = url.pathname.split('/').filter(Boolean).pop(); - const { blob, filename } = await waha.downloadMedia(reqID); + const { blob, filename } = await getMedia(reqID); const objectUrl = URL.createObjectURL(blob); e.target.href = objectUrl; diff --git a/utils.js b/js/utils.js similarity index 100% rename from utils.js rename to js/utils.js diff --git a/waha.js b/js/waha.js similarity index 100% rename from waha.js rename to js/waha.js diff --git a/websocket.js b/js/websocket.js similarity index 100% rename from websocket.js rename to js/websocket.js