Save chats, messages and media to IndexedDB

This commit is contained in:
天クマ 2026-07-16 15:26:42 -03:00
commit 3a1ed0903c
9 changed files with 348 additions and 67 deletions

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" user-scalable=no>
<title>Pandora</title> <title>Pandora</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@ -10,7 +10,6 @@
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="loading.css"> <link rel="stylesheet" href="loading.css">
<link rel="stylesheet" href="metroicons.css"> <link rel="stylesheet" href="metroicons.css">
<script src="https://unpkg.com/lucide@latest" defer></script>
</head> </head>
<body> <body>
<script defer> <script defer>
@ -153,6 +152,6 @@
</div> </div>
<!-- Client-side script loaded as ES Module --> <!-- Client-side script loaded as ES Module -->
<script type="module" src="app.js"></script> <script type="module" src="./js/app.js"></script>
</body> </body>
</html> </html>

View file

@ -3,20 +3,31 @@ import { waha } from "./waha.js";
import { ui, elements } from "./ui.js"; import { ui, elements } from "./ui.js";
import { websocket } from "./websocket.js"; import { websocket } from "./websocket.js";
import { compensateMessageOrdering, formatTime } from "./utils.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 activeChatState = null;
let userInfo; let userInfo;
const messageTone = new Audio("./message.ogg"); const messageTone = new Audio("./message.ogg");
// Initialize app when DOM is ready
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
setupEventListeners(); setupEventListeners();
try { try {
userInfo = await waha.getMyInfo(); elements.loggedUserName.textContent = await getAppUser().pushName;
fetchChats(); loadChats();
checkWahaStatus(); checkWahaStatus();
initWebSocket(); initWebSocket();
} finally {
elements.chatsLoader.classList.add('hidden');
}
});
function loadChats() {
try {
elements.chatsLoader.classList.remove('hidden');
fetchChats().then(() => {
ui.renderChatList(getChats(), activeChatState, selectChat);
});
} catch (error) { } catch (error) {
console.error('Failed to load chats:', error); console.error('Failed to load chats:', error);
elements.chatList.innerHTML = ` elements.chatList.innerHTML = `
@ -31,7 +42,7 @@ document.addEventListener('DOMContentLoaded', async () => {
} finally { } finally {
elements.chatsLoader.classList.add('hidden'); elements.chatsLoader.classList.add('hidden');
} }
}); }
function setupEventListeners() { function setupEventListeners() {
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
@ -43,16 +54,15 @@ function setupEventListeners() {
elements.refreshChatsBtn.addEventListener('click', () => { elements.refreshChatsBtn.addEventListener('click', () => {
elements.refreshChatsBtn.classList.add('spinning'); elements.refreshChatsBtn.classList.add('spinning');
fetchChats().finally(() => { loadChats()
setTimeout(() => { setTimeout(() => {
elements.refreshChatsBtn.classList.remove('spinning'); elements.refreshChatsBtn.classList.remove('spinning');
}, 600); }, 600);
});
}); });
elements.chatSearch.addEventListener('input', (e) => { elements.chatSearch.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase(); const query = e.target.value.toLowerCase();
const filtered = chatsState.filter(chat => const filtered = getChats().filter(chat =>
chat.name.toLowerCase().includes(query) chat.name.toLowerCase().includes(query)
); );
ui.renderChatList(filtered, activeChatState, selectChat); ui.renderChatList(filtered, activeChatState, selectChat);
@ -99,6 +109,7 @@ function initWebSocket() {
const ev = data.event; const ev = data.event;
if (ev === 'message' || ev === 'message.any' || ev === 'message.ack') { if (ev === 'message' || ev === 'message.any' || ev === 'message.ack') {
handleIncomingMessage(data.payload); handleIncomingMessage(data.payload);
upsertMessages([data.payload]);
} }
}); });
} }
@ -115,7 +126,7 @@ function handleIncomingMessage(msg) {
if (!msg) return; if (!msg) return;
// console.log('[WS] handleIncomingMessage payload:', msg); // console.log('[WS] handleIncomingMessage payload:', msg);
ui.updateChatInChatList(msg); ui.updateChatInChatList(msg);
const rawChatId = msg.chatId || msg.from || (msg.chat && msg.chat.id); 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) { if (chatIndex !== -1) {
const chat = chatsState[chatIndex]; const chat = getChats()[chatIndex];
chat.lastMessage = msg.body || msg.text || 'Media message'; chat.lastMessage = msg.body || msg.text || 'Media message';
chat.timestamp = msg.timestamp ? (msg.timestamp * 1000) : Date.now(); chat.timestamp = msg.timestamp ? (msg.timestamp * 1000) : Date.now();
if (!msg.fromMe && (!activeChatState || activeChatState.id !== msgChatId)) { if (!msg.fromMe && (!activeChatState || activeChatState.id !== msgChatId)) {
chat.unreadCount = (chat.unreadCount || 0) + 1; 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 = `
<li class="loading-chats" style="color: var(--text-primary); text-align: center; padding: 20px;">
<p>Connection to WAHA failed.</p>
<p style="font-size: 0.75rem; color: var(--text-primary); margin-top: 8px;">
Ensure WAHA server is running and CORS is enabled, or click Settings to configure.
</p>
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 8px;">${error.message}</p>
</li>
`;
} finally {
elements.chatsLoader.classList.add('hidden');
} }
} }
@ -213,9 +191,9 @@ async function selectChat(chat) {
} }
try { try {
const rawMessages = await waha.getChatMessages(chat.id); const rawMessages = await getChatMessages(chat.id);
const processedMessages = compensateMessageOrdering(rawMessages); const processedMessages = compensateMessageOrdering(rawMessages);
ui.renderMessages(processedMessages, chat.name, userInfo.id, chat.id); ui.renderMessages(processedMessages, chat.name, getAppUser().id, chat.id);
} catch (error) { } catch (error) {
console.error('Failed to load messages:', error); console.error('Failed to load messages:', error);
elements.messagesContainer.innerHTML = '<div class="loading-chats">Error loading messages</div>'; elements.messagesContainer.innerHTML = '<div class="loading-chats">Error loading messages</div>';
@ -276,13 +254,6 @@ async function sendMessage() {
activeChatState.lastMessage = text; activeChatState.lastMessage = text;
activeChatState.timestamp = new Date(); 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) { } catch (error) {
console.error('Failed to send message:', error); console.error('Failed to send message:', error);
const tempBubble = document.getElementById(tempMsg.id); const tempBubble = document.getElementById(tempMsg.id);
@ -337,7 +308,7 @@ function saveSettings() {
elements.inputApiKey.value elements.inputApiKey.value
); );
ui.toggleModal(false); ui.toggleModal(false);
fetchChats(); loadChats();
checkWahaStatus(); checkWahaStatus();
initWebSocket(); initWebSocket();
} }

204
js/db.js Normal file
View file

@ -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);
});
}

108
js/storage.js Normal file
View file

@ -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 "";
}
}

View file

@ -1,6 +1,7 @@
import { formatTime } from "./utils.js"; import { formatTime } from "./utils.js";
import { waha } from "./waha.js"; import { waha } from "./waha.js";
import { config } from "./config.js"; import { config } from "./config.js";
import { getChatPicture, getMessage, getMedia } from "./storage.js";
// DOM Selector Cache // DOM Selector Cache
export const elements = { export const elements = {
@ -92,12 +93,11 @@ export const ui = {
} }
for (const chat of chats) { for (const chat of chats) {
console.log(chat);
const li = document.createElement('li'); const li = document.createElement('li');
li.className = `chat-item ${activeChat && activeChat.id === chat.id ? 'active' : ''}`; li.className = `chat-item ${activeChat && activeChat.id === chat.id ? 'active' : ''}`;
li.dataset.id = chat.id; 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 initials = chat.name ? chat.name.substring(0, 1).toUpperCase() : '?';
const hasUnread = chat.unreadCount && chat.unreadCount > 0; const hasUnread = chat.unreadCount && chat.unreadCount > 0;
const timeStr = formatTime(chat.timestamp || new Date()); const timeStr = formatTime(chat.timestamp || new Date());
@ -141,7 +141,6 @@ export const ui = {
this.appendSingleMessage(msg, activeChatName, userID, chatId); this.appendSingleMessage(msg, activeChatName, userID, chatId);
} }
lucide.createIcons();
this.scrollToBottom(); this.scrollToBottom();
}, },
@ -171,6 +170,7 @@ export const ui = {
}, },
generateMessage(msg, activeChatName, userID, chatId, isLocal = false) { generateMessage(msg, activeChatName, userID, chatId, isLocal = false) {
console.log(msg);
const isOutgoing = msg.fromMe || msg.sender === 'me'; const isOutgoing = msg.fromMe || msg.sender === 'me';
function getPrevMessageElem() { function getPrevMessageElem() {
@ -238,12 +238,11 @@ export const ui = {
const clickListener = async (e) => { const clickListener = async (e) => {
a.removeEventListener('click', clickListener); a.removeEventListener('click', clickListener);
a.innerText = `[Downloading]`; a.innerText = `[Downloading]`;
const mediaMsg = msg.media ? msg : await waha.getSingleChatMessage(chatId, msg.id, true); const mediaMsg = msg.media ? msg : await getMessage(chatId, msg.id, true);
console.log(mediaMsg.media.url);
const url = new URL(mediaMsg.media.url); const url = new URL(mediaMsg.media.url);
const reqID = url.pathname.split('/').filter(Boolean).pop(); 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); const objectUrl = URL.createObjectURL(blob);
e.target.href = objectUrl; e.target.href = objectUrl;

View file