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