Compare commits

...

38 commits

Author SHA1 Message Date
5ba95800a3 Only load when not already loading one. Don't animate .selectable on hover. 2026-07-18 14:42:18 -03:00
cce7b74068 Add user page with profile picture, number and about. change font to Selawik. 2026-07-18 12:02:20 -03:00
0af3395ad7 Add manifest.json, prevent pinch to zoom on mobile, make body dodge mobile keyboard and add partial light theme. 2026-07-17 23:21:57 -03:00
天クマ
09e31d8d71
Update README.md 2026-07-17 22:13:07 -03:00
73e0496260 Change loading animation to Sven Scharfenberg's that does not rely on JS. 2026-07-17 21:07:08 -03:00
1116228151 Update readme 2026-07-17 16:08:01 -03:00
36f8448bf9 Add config option to configure chat background 2026-07-17 16:06:29 -03:00
cad283e06b Lazy-load chat images; scroll to bottom on incoming message only when user has not scrolled up. 2026-07-17 15:22:26 -03:00
dcd40790e0 Make chat a page and add sidebar with buttons to cycle through pages on desktop. 2026-07-17 13:46:24 -03:00
757ae0d2b4 Remove unecessary css clashing with scrolling on mobile 2026-07-17 11:29:55 -03:00
ff6ae0c734 Make open chat persistent on mobile 2026-07-17 11:26:53 -03:00
b1f7938b37 Move settings to extra pages 2026-07-17 11:23:41 -03:00
aa42223fc9 Add sliding and sliding animations on mobile. 2026-07-17 11:08:49 -03:00
00d22f7769 Update readme 2026-07-16 22:36:40 -03:00
dc5db6aac6 Fix duplicate messages arriving through websocket 2026-07-16 22:35:15 -03:00
f6310d3065 Merge remote-tracking branch 'github/master' 2026-07-16 21:29:27 -03:00
cedbd2b3c0 Add pagination to message screen. 2026-07-16 21:26:32 -03:00
0b9ed3a68a Make chat bubble smaller (90%) on mobile. 2026-07-16 20:05:53 -03:00
b3b74b04fc Fix bug in updateChatInChatList that would make outgoing messages show up in the user self chat; fix app awaiting for getAppUser on wrong scope. 2026-07-16 20:03:32 -03:00
a021ca821f Remove usage of userInfo in app.js; make storage aware of connection status to avoid connecting to the server in offline mode; fix updateChaInList querying for non-existent elements and using them blindly; make websocket also check connection status on reload. 2026-07-16 19:36:25 -03:00
3a1ed0903c Save chats, messages and media to IndexedDB 2026-07-16 15:26:42 -03:00
819c16d02f Hide user name on subsequent messages 2026-07-16 08:49:47 -03:00
9480c600f9 Don't play notification sound when msg.fromMe 2026-07-16 08:29:54 -03:00
天クマ
08c92241de
Create README.md 2026-07-14 23:02:30 -03:00
3ba0f9792c Add notification sound. 2026-07-14 20:28:16 -03:00
fe86db2987 Make generateMessage aware of temp messages, make sent attachements show on chat 2026-07-14 19:10:59 -03:00
e388e185ba Make media links open in a new tab, removed some logs. 2026-07-14 18:10:05 -03:00
5cfd51fdb6 Infer attachment type from file mime type and change endpoint accordingly. 2026-07-14 18:04:41 -03:00
6af2d4a1ea Load images in messages automatically 2026-07-14 17:45:24 -03:00
7269cd868b Fix renderChatList making images with null href. 2026-07-14 16:35:53 -03:00
ccb1233181 Add functionality to attachment button, fix escape ev listener preventing all events default behavior. 2026-07-14 16:28:33 -03:00
25004fe404 Add collapsable bottom bar to chat screen. 2026-07-14 15:28:42 -03:00
a4ac6146d7 Remove Android build with Capacitor 2026-07-14 10:47:35 -03:00
0bf03df75c Alow https 2026-07-13 22:36:08 -03:00
85038b45a3 Ported to Cordova, it sucks though, I'm trying Capacitor tomorrow. 2026-07-13 22:29:54 -03:00
1c586b6bcb . 2026-07-13 16:47:32 -03:00
0289db28e5 Add .nojekyll for github pages 2026-07-13 11:25:55 -03:00
9d77ea5a97 Change layout to look more like WhatsApp for Windows Phone and less like AI slop. 2026-07-13 11:24:19 -03:00
33 changed files with 2310 additions and 2003 deletions

31
.gitignore vendored
View file

@ -1,2 +1,29 @@
.env
node_modules
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
.DS_Store
# Generated by package manager
node_modules/
# Generated by Cordova
/plugins/
/platforms/
pandora.apks
/android/app/build/
test-upload-key.jks

0
.nojekyll Normal file
View file

48
README.md Normal file
View file

@ -0,0 +1,48 @@
<img width="1920" height="1080" alt="PANDORA" src="https://github.com/user-attachments/assets/d9d7ba36-4510-47e1-8c49-b1977a26c448" />
Pandora is an open-source web client for the WAHA API.
## Features
- [x] Contact list<sup>1</sup>
- [x] Chat screen<sup>2</sup>
- [x] Sending messages
- [ ] Sending polls
- [X] Receiving messages (WebSocket)<sup>3</sup>
- [ ] Receiving polls
- [ ] Answering polls
- [ ] Sending audio messages
- [x] Sending attachments (sends image/video as such)<sup>4</sup>
- [x] Notification sound<sup>5</sup>
- [ ] Browser notification
- [x] Download attachments
- [x] Automatically download images<sup>6</sup>
- [X] Local message storing
- [ ] Message encryption
- [ ] Light theme
- [X] Custom wallpaper
1. Does not show contact name in any engine other than WEBJS
2. Loads the last 40 messages
3. Will not contain contact name in groups if you're in any engine other than WEBJS
4. You will receive a duplicate of your attachment in any engine other than WEBJS
5. Allow autoplay to avoid blocking
6. Only on WEBJS engine
## Setup
1. Check [WAHA Docs](https://waha.devlike.pro/docs/) to setup WAHA
2. Serve Pandora in any HTTP server. The GitHub Pages for this repository will only work for WAHA servers over HTTPS (due to mixed content)
3. It works better with WEBJS engine.
4. Access Pandora and change the server address, session and API key to match your server
5. You should probably be good to go
## Frequently Asked Questions
### Why?
Because WhatsApp won't run on my LineageOS. Seems like the APK from WhatsApp's own website doesn't count official.
Sadly there are places in the world where you need this bullshit application to be a functional human being.
### Will this get me banned?
It's against WhatsApp TOS, though it mocks message typing and should not be able to be identified so easily. I haven't got myself nor heard anyone getting in trouble for using WAHA correctly.
### How it works
Pure magic. (Mocks a WhatsApp Web session)

BIN
fonts/selawk.ttf Normal file

Binary file not shown.

BIN
fonts/selawkb.ttf Normal file

Binary file not shown.

BIN
fonts/selawkl.ttf Normal file

Binary file not shown.

BIN
fonts/selawksb.ttf Normal file

Binary file not shown.

BIN
fonts/selawksl.ttf Normal file

Binary file not shown.

BIN
icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

173
index.html Normal file
View file

@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content" />
<meta name="HandheldFriendly" content="true" />
<title>Pandora</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="loading.css">
<link rel="stylesheet" href="metroicons.css">
<link rel="manifest" href="./manifest.json">
</head>
<body>
<div class="app-container no-active-chat">
<aside id="desktop-aside">
<button id="profile-sidebar-btn" class="icon-btn mif-person mif-2x" data-page="profile-page"></button>
<button id="chats-sidebar-btn" class="icon-btn mif-qa mif-2x" data-page="chat-page"></button>
<button id="settings-sidebar-btn" class="icon-btn mif-cog mif-2x" data-page="settings-page"></button>
</aside>
<!-- Sidebar -->
<aside class="sidebar">
<header class="sidebar-header">
<p class="app-name">PANDORA</p>
<div class="user-profile">
<!-- <div id="pandora-user-icon" class="avatar user-avatar">P</div> -->
<div class="user-info">
<h3 id="pandora-username" data-content="app-user">Pandora User</h3>
</div>
</div>
</header>
<div class="search-container">
<div class="search-wrapper">
<input type="text" id="chat-search" placeholder="Search or start new chat...">
</div>
</div>
<div class="chat-list-container">
<div class="loading-chats" id="chats-loader">
<div class='loading-animation-wrapper'>
<div class="animation">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</div>
</div>
<ul class="chat-list" id="chat-list">
<!-- Chats will be dynamically injected here -->
</ul>
</div>
</aside>
<div class="extra-page" id="chat-page">
<!-- Main Chat Area -->
<main class="chat-area">
<!-- No Chat Selected State -->
<div class="no-chat-state" id="no-chat-state">
<div class="empty-state-content">
<div class="empty-state-icon mif-qa mif-3x">
</div>
<p>Select a contact to view the conversation or start a new chat.</p>
</div>
</div>
<!-- Active Chat State -->
<div class="active-chat-container hidden" id="active-chat-container">
<header class="chat-header">
<div class="active-contact-info">
<!-- <button class="icon-btn back-btn mif-arrow-left mif-2x" id="back-to-sidebar" title="Back to chats"></button> -->
<div class="avatar active-avatar" id="active-chat-avatar">C</div>
<div>
<h3 id="active-chat-name">Contact Name</h3>
<span class="contact-status" id="active-chat-status"></span>
</div>
</div>
<div class="chat-actions">
<!-- <button class="icon-btn" title="Search in conversation"><i data-lucide="search"></i></button>
<button class="icon-btn" title="Call"><i data-lucide="phone"></i></button>
<button class="icon-btn" title="Video Call"><i data-lucide="video"></i></button>
<button class="icon-btn" title="More Options"><i data-lucide="more-vertical"></i></button> -->
</div>
</header>
<!-- Messages Area -->
<div class="messages-container" id="messages-container">
<!-- Messages will be dynamically injected here -->
</div>
<!-- Input Panel -->
<footer id="chat-input-panel" class="chat-input-panel">
<div class="input-actions-left">
<button id="chat-bottom-bar-btn" class="icon-btn mif-expand-less mif-3x" title="Expand"></button>
</div>
<form class="input-form" id="message-form">
<input type="text" id="message-input" placeholder="Type a message..." autocomplete="off">
<button type="submit" class="send-btn mif-paper-plane mif-3x" id="send-button">
</button>
</form>
</footer>
<!-- Bottom Bar -->
<footer id="chat-bottom-bar" class="chat-expanded-panel alternate-panel collapsed">
<input id="attachment-input" style="display: none;" type="file">
<button id="attachment-btn" class="icon-btn send-btn mif-attachment mif-2x" title="Attach file"></button>
</footer>
</div>
</main>
</div>
<section class="extra-page" id="profile-page">
<h2 data-content="app-user">Pandora User</h2>
<div class="content">
<img id="profile-page-picture" data-resource="app-user-image">
<div id="profile-page-user-info">
<p id="profile-page-user-number" data-content="app-user-number">Pandora User</p>
<input id="profile-page-status-input" placeholder="enter your status" data-value="app-user-status">
</div>
</div>
</section>
<section class="extra-page" id="settings-page">
<div class="modal-content">
<header class="modal-header">
<h2>SETTINGS</h2>
</header>
<div class="modal-body">
<h3>server</h3>
<div class="form-group">
<label for="settings-waha-url">WAHA Server URL</label>
<input type="text" id="settings-waha-url" placeholder="http://localhost:3100">
</div>
<div class="form-group">
<label for="settings-session">Session ID</label>
<input type="text" id="settings-session" placeholder="session_01...">
</div>
<div class="form-group">
<label for="settings-api-key">API Key (X-API-KEY)</label>
<input type="password" id="settings-api-key" placeholder="Enter API Key">
</div>
<p class="settings-warning">Note: Requests are sent directly from your browser to the WAHA server. Make sure CORS is enabled on your WAHA instance.</p>
<h3>chats</h3>
<div class="form-group">
<label for="settings-background-image">Chat background image</label>
<input type="text" id="settings-background-image" placeholder="Enter image URL">
</div>
<div class="form-group">
<label for="settings-background-opacity">Chat background opacity</label>
<input type="number" min="0" max="1" step="0.1" id="settings-background-opacity" placeholder="Enter opacity value">
</div>
</div>
<footer class="modal-footer">
<button class="btn primary-btn" id="save-settings">Save</button>
</footer>
<div class="sidebar-footer">
<div class="api-status-badge">
<span class="pulse-dot"></span>
<span id="backend-status-text">Backend connected</span>
</div>
</div>
</div>
</section>
</div>
<!-- Client-side script loaded as ES Module -->
<script type="module" src="./js/app.js"></script>
</body>
</html>

View file

@ -1,21 +0,0 @@
import express from "express";
import path from "path";
import { fileURLToPath } from "url";
const app = express();
const PORT = process.env.PORT || 3100;
// Resolve __dirname in ES modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Serve static assets from the public folder
app.use(express.static(path.join(__dirname, "public")));
// Start static file server
app.listen(PORT, () => {
console.log(`==================================================`);
console.log(` Pandora Chat Static Server is running!`);
console.log(` URL: http://localhost:${PORT}`);
console.log(`==================================================`);
});

451
js/app.js Normal file
View file

@ -0,0 +1,451 @@
import { config } from "./config.js";
import { waha } from "./waha.js";
import { ui, elements } from "./ui.js";
import { websocket } from "./websocket.js";
import { compensateMessageOrdering, debounce, formatTime, normalizeId } from "./utils.js";
import { fetchChats, getAppUser, getChatMessages, getChatPicture, getChats, getUser, getUserAbout, sendStatus, updateOnlineStatus } from "./storage.js";
import { upsertMessages } from "./db.js";
let activeChatState = null;
const messageTone = new Audio("./message.ogg");
const longPressEvent = new CustomEvent("longpress");
export let isLoadingChat = false;
document.addEventListener('DOMContentLoaded', async () => {
elements.inputApiKey.value = config.apiKey;
elements.inputWahaUrl.value = config.wahaUrl;
elements.inputSession.value = config.session;
elements.inputBackgroundImage.value = config.bgImg;
elements.inputBackgroundOpacity.value = config.bgOpacity;
elements.activeChatContainer.style.setProperty('--background-image', `URL("${config.bgImg}")`);
elements.activeChatContainer.style.setProperty('--background-opacity', `${config.bgOpacity}`);
await updateOnlineStatus();
setupEventListeners();
try {
setupElementsData();
loadChats();
checkWahaStatus();
initWebSocket();
} finally {
elements.chatsLoader.classList.add('hidden');
}
});
async function setupElementsData() {
const usr = await getAppUser();
const usrPic = (await getChatPicture(usr.id))?.url;
const usrInfo = await getUser(usr.id);
const usrAbout = (await getUserAbout(usr.id)).about;
elements.contentUserName.forEach(e => {
e.innerHTML = usr.pushName;
})
elements.contentUserNumber.forEach(async e => {
e.innerHTML = usrInfo.number;
})
elements.resourceUserPic.forEach(async e => {
e.src = usrPic;
})
elements.valueUserStatus.forEach(async e => {
e.value = usrAbout.trim();
})
}
function loadChats() {
elements.chatsLoader.classList.remove('hidden');
try {
fetchChats().then(async () => {
ui.renderChatList(getChats(), activeChatState, selectChat);
const hash = window.location.hash;
if (hash && hash.startsWith('#chat-')) {
const chatId = hash.replace('#chat-', '');
const chat = getChats().find(c => c.id === chatId);
if (chat) {
selectChat(chat, true, false);
}
}
});
} 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');
}
}
let isScrollingProgrammatically = false;
let scrollTimeout = null;
function scrollToChat(smooth = true) {
isScrollingProgrammatically = true;
elements.appContainer.scrollTo({
left: elements.appContainer.clientWidth,
behavior: smooth ? 'smooth' : 'auto'
});
setTimeout(() => { isScrollingProgrammatically = false; }, smooth ? 400 : 50);
}
function scrollToList(smooth = true) {
isScrollingProgrammatically = true;
elements.appContainer.scrollTo({
left: 0,
behavior: smooth ? 'smooth' : 'auto'
});
setTimeout(() => { isScrollingProgrammatically = false; }, smooth ? 400 : 50);
}
function setupEventListeners() {
if (!window.location.hash) {
window.location.hash = '';
}
window.addEventListener('hashchange', () => {
const hash = window.location.hash;
if (hash && hash.startsWith('#chat-')) {
const chatId = hash.replace('#chat-', '');
const chat = getChats().find(c => c.id === chatId);
if (chat) {
selectChat(chat, true);
}
} else {
closeActiveChat(true);
}
});
elements.appContainer.addEventListener('scroll', () => {
if (window.innerWidth > 768) return;
if (isScrollingProgrammatically) return;
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const scrollLeft = elements.appContainer.scrollLeft;
const width = elements.appContainer.clientWidth;
if (scrollLeft < width * 0.2) {
// User swiped back to the list view
if (activeChatState) {
closeActiveChat(false);
}
}
}, 100);
});
document.addEventListener('keydown', (e) => {
if (e.code == "Escape") {
e.preventDefault();
closeActiveChat(false);
}
});
elements.chatSearch.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const filtered = getChats().filter(chat =>
chat.name.toLowerCase().includes(query)
);
ui.renderChatList(filtered, activeChatState, selectChat);
});
elements.messageForm.addEventListener('submit', (e) => {
e.preventDefault();
sendMessage();
});
elements.chatBottomBar.style.height = `${elements.chatInputPanel.offsetHeight}px`;
const observer = new ResizeObserver(() => {
elements.chatBottomBar.style.height =
`${elements.chatInputPanel.offsetHeight}px`;
});
observer.observe(elements.chatInputPanel);
elements.chatBottomBarBtn.addEventListener('click', ui.toggleChatBottomBar);
elements.chatBottomBar.addEventListener('click', (e) => {
if (e.target == e.currentTarget) ui.toggleChatBottomBar();
});
elements.attachmentBtn.addEventListener('click', () => {
elements.attachmentInput.click();
})
elements.attachmentInput.addEventListener('change', function () {
const firstFile = this.files[0];
sendFileMessage(firstFile);
})
elements.backToSidebarBtn.addEventListener('click', () => {
closeActiveChat(false);
});
elements.desktopSidebarButtons.forEach(sidebarBtn => {
sidebarBtn.addEventListener('click', () => {
ui.showExtraPage(sidebarBtn.dataset.page);
})
})
elements.saveSettingsBtn.addEventListener('click', saveSettings);
elements.inputUserStatus.addEventListener('input', debounce(async function() {
const result = await sendStatus(elements.inputUserStatus.value);
console.log(result);
}, 2000))
elements.selectable.forEach(e => {
let timerId, longPressed;
e.addEventListener('mousedown', () => {
longPressed = false;
timerId = setTimeout(() => {
longPressed = true;
e.dispatchEvent(longPressEvent);
})
})
e.addEventListener('click', () => {
if (longPressed) {
e.preventDefault();
clearTimeout(timerId);
}
})
e.addEventListener('mouseleave', () => {
clearTimeout(timerId);
})
})
}
function initWebSocket() {
websocket.connect((data) => {
// console.log('[WS] Received event:', data.event, data);
const ev = data.event;
if (ev === 'message' || ev === 'message.any' || ev === 'message.ack') {
handleIncomingMessage(data.payload);
upsertMessages([data.payload]);
}
});
}
async function handleIncomingMessage(msg) {
if (!msg) return;
ui.updateChatInChatList(msg);
const rawChatId = msg.chatId || msg.from || (msg.chat && msg.chat.id);
const msgChatId = normalizeId(rawChatId);
if (!msgChatId) {
console.warn('[WS] Could not resolve chatId from payload:', msg);
return;
}
// console.log('[WS] Resolved msgChatId:', msgChatId, '| activeChatState:', activeChatState?.id);
if (!msg.fromMe) {
messageTone.play();
}
if (activeChatState && activeChatState.id === msgChatId) {
const msgId = normalizeId(msg.id) || msg.id;
const exists = document.getElementById(msgId);
if (!exists) {
const scrolled = elements.messagesContainer.scrollTop == elements.messagesContainer.scrollTopMax;
ui.appendSingleMessage({ ...msg, chatId: msgChatId }, activeChatState.name, (await getAppUser()).id);
if (scrolled) {
ui.scrollToBottom();
}
}
}
}
async function selectChat(chat, isPopState = false, smoothScroll = true) {
if (isLoadingChat) return;
isLoadingChat = true;
activeChatState = chat;
chat.unreadCount = 0;
// ui.renderChatList(chatsState, activeChatState, selectChat);
ui.toggleChatState(true);
elements.activeChatName.textContent = chat.name.toUpperCase();
elements.activeChatAvatar.textContent = chat.name ? chat.name.substring(0, 1).toUpperCase() : '?';
elements.messagesContainer.innerHTML = `
<div class='loading-animation-wrapper'>
<div class="animation">
<p class="animation"></p>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</div>`;
elements.appContainer.classList.remove('no-active-chat');
if (window.innerWidth <= 768) {
scrollToChat(smoothScroll);
}
if (!isPopState && window.location.hash !== `#chat-${chat.id}`) {
window.location.hash = ``;
window.location.hash = `chat-${chat.id}`;
}
try {
const rawMessages = await getChatMessages(chat.id);
const processedMessages = compensateMessageOrdering(rawMessages);
ui.renderMessages(processedMessages, chat.name, (await getAppUser()).id, chat.id);
} catch (error) {
console.error('Failed to load messages:', error);
elements.messagesContainer.innerHTML = '<div class="loading-chats">Error loading messages</div>';
}
isLoadingChat = false;
}
async function closeActiveChat(isPopState = false) {
activeChatState = null;
if (window.innerWidth <= 768) {
scrollToList();
} else {
ui.toggleChatState(false);
}
if (!isPopState) {
if (window.location.hash.startsWith('#chat-')) {
history.back();
}
}
}
async function sendMessage() {
const text = elements.messageInput.value.trim();
if (!text || !activeChatState) return;
elements.messageInput.value = '';
const tempMsg = {
id: 'temp-' + Date.now(),
body: text,
fromMe: true,
sender: 'me',
timestamp: new Date().toISOString(),
status: 'sending'
};
ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id);
ui.scrollToBottom();
try {
try {
await waha.startTyping(activeChatState.id);
const delay = Math.min(4000, Math.max(1000, text.length * 50));
await new Promise(resolve => setTimeout(resolve, delay));
} catch (e) {
console.warn('Presence start failed:', e);
}
try {
await waha.stopTyping(activeChatState.id);
} catch (e) {
console.warn('Presence stop failed:', e);
}
try {
if (!activeChatState.id.endsWith('@lid')) {
await waha.readChat(activeChatState.id);
}
} catch (e) {
console.warn('readChat failed (non-fatal):', e.message);
}
const responseData = await waha.sendTextMessage(activeChatState.id, text);
const tempBubble = document.getElementById(tempMsg.id);
if (tempBubble) {
if (responseData && responseData.id) {
tempBubble.id = normalizeId(responseData.id);
}
const meta = tempBubble.querySelector('.message-meta');
meta.innerHTML = `<span>${formatTime(new Date())}</span><span style="width:14px; height:14px;" class="mif-done">`;
}
activeChatState.lastMessage = text;
activeChatState.timestamp = new Date();
} catch (error) {
console.error('Failed to send message:', error);
const tempBubble = document.getElementById(tempMsg.id);
if (tempBubble) {
const meta = tempBubble.querySelector('.message-meta');
meta.innerHTML = `<span style="color: #ef4444;">Failed to send</span>`;
}
}
}
async function sendFileMessage(file) {
try {
const tempId = 'temp-' + Date.now();
const tempMsg = {
_data: {
mimetype: file.type
},
id: tempId,
body: "",
fromMe: true,
sender: 'me',
timestamp: new Date().toISOString(),
status: 'sending',
hasMedia: true,
media: {
url: URL.createObjectURL(file),
filename: file.name
}
};
ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id, activeChatState.id, true);
ui.scrollToBottom();
const result = await waha.sendFileMessage(activeChatState.id, file);
ui.removeChatMessage(tempId);
ui.appendSingleMessage(result.id, activeChatState.name, (await getAppUser()).id, activeChatState.id);
} catch (error) {
console.error(error.message);
}
}
function saveSettings() {
config.save(
elements.inputWahaUrl.value,
elements.inputSession.value,
elements.inputApiKey.value,
elements.inputBackgroundImage.value,
elements.inputBackgroundOpacity.value
);
location.reload();
loadChats();
checkWahaStatus();
initWebSocket();
}
async function checkWahaStatus() {
try {
const data = await waha.getVersion();
ui.updateConnectionStatus(true, `WAHA Connected: v${data.version || 'OK'}`);
} catch (e) {
ui.updateConnectionStatus(false, 'WAHA Server Offline');
}
}
export function getCurrentChat() {
return activeChatState;
}

View file

@ -4,14 +4,20 @@ export const config = {
wahaUrl: localStorage.getItem('waha_url') || 'http://inspiran.beetal-castor.ts.net:3100',
session: localStorage.getItem('waha_session') || 'session_01kxc62bk5fs8vh4v127k88a7j',
apiKey: localStorage.getItem('waha_api_key') || '',
bgImg: localStorage.getItem('background_image') || '',
bgOpacity: localStorage.getItem('background_opacity') || '0.4',
save(url, session, apiKey) {
save(url, session, apiKey, bgImg, bgOpacity) {
this.wahaUrl = url.trim().replace(/\/$/, "");
this.session = session.trim();
this.apiKey = apiKey.trim();
this.bgImg = bgImg.trim();
this.bgOpacity = bgOpacity.trim();
localStorage.setItem('waha_url', this.wahaUrl);
localStorage.setItem('waha_session', this.session);
localStorage.setItem('waha_api_key', this.apiKey);
localStorage.setItem('background_image', this.bgImg);
localStorage.setItem('background_opacity', this.bgOpacity);
}
};

232
js/db.js Normal file
View file

@ -0,0 +1,232 @@
import { normalizeId } from "./utils.js";
const DB_NAME = "pandora";
const DB_VERSION = 6;
let dbPromise = null;
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 });
msgStore.createIndex("cidTimestampId", ["chatId", "timestamp", "id"], { 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: normalizeId(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 loadOlderMessages(chatId, oldestTimestamp, oldestId, 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("cidTimestampId");
const out = [];
const parsedTimestamp = isNaN(oldestTimestamp) ? oldestTimestamp : Number(oldestTimestamp);
const range = IDBKeyRange.bound(
[chatId, -Infinity, ""],
[chatId, parsedTimestamp, oldestId],
false,
false
);
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);
});
}

150
js/storage.js Normal file
View file

@ -0,0 +1,150 @@
import { loadChatsSorted, loadLatestMessages, loadMedia, loadOlderMessages, upsertChats, upsertMedia, upsertMessages } from "./db.js";
import { waha } from "./waha.js";
let online = false;
let chats = [];
export async function updateOnlineStatus() {
try {
await waha.getVersion();
online = true;
} catch (error) {
console.log(error);
online = false;
}
}
export async function fetchChats() {
if (online) {
await getRemoteChats()
}
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 async function getUser(number) {
if (online) {
return await waha.getUser(number);
} else {
return;
}
}
export async function getUserAbout(userId) {
if (online) {
return await waha.getUserAbout(userId);
} else {
return;
}
}
export function getChats() {
return chats;
}
export async function getAppUser() {
if (online) {
const info = await waha.getMyInfo();
localStorage.setItem('pandora-last-username', info.name);
localStorage.setItem('pandora-last-userid', info.id);
return info;
} else {
return {
pushName: localStorage.getItem('pandora-last-username') || 'Unknown',
id: localStorage.getItem('pandora-last-userid') || 'Unknown'
}
}
}
export async function getMessage(chatId, msgId, downloadMedia) {
if (online) {
const newMessage = await waha.getSingleChatMessage(chatId, msgId, downloadMedia);
upsertMessages([newMessage]);
return newMessage;
} else {
return {
id: `${Date.now()}-temp`,
body: "You're offline",
from: "system",
timestamp: new Date().toISOString()
};
}
}
export async function getMedia(reqId) {
const cached = await loadMedia(reqId);
if (cached) {
return cached;
}
try {
if (online) {
const media = await waha.downloadMedia(reqId);
upsertMedia(reqId, media.blob, media.filename);
return media;
}
} catch (error) {
return;
}
}
export async function getChatMessages(chatId) {
if (online) {
const newMessages = await waha.getChatMessages(chatId);
upsertMessages(newMessages);
return newMessages;
} else {
return await loadLatestMessages(chatId);
}
}
export async function getMoreChatMessages(chatId, oldestTimestamp, oldestId, limit = 50) {
if (online) {
return waha.getChatMessages(chatId, oldestTimestamp);
} else {
return await loadOlderMessages(chatId, oldestTimestamp, oldestId);
}
}
export async function getChatPicture(chatId) {
if (online) {
return await waha.getChatPicture(chatId);
} else {
return { url: "" };
}
}
export function isOnline() {
return online;
}
export async function sendStatus(text) {
if (online) {
return await waha.setStatus(text);
} else {
return {
success: false
}
}
}

396
js/ui.js Normal file
View file

@ -0,0 +1,396 @@
import { formatTime, normalizeId } from "./utils.js";
import { getChatPicture, getMessage, getMedia, getMoreChatMessages } from "./storage.js";
import { getCurrentChat } from "./app.js";
export const elements = {
chatList: document.getElementById('chat-list'),
chatsLoader: document.getElementById('chats-loader'),
chatSearch: document.getElementById('chat-search'),
backendStatusText: document.getElementById('backend-status-text'),
apiStatusIndicator: document.querySelector('.pulse-dot'),
noChatState: document.getElementById('no-chat-state'),
activeChatContainer: document.getElementById('active-chat-container'),
activeChatName: document.getElementById('active-chat-name'),
activeChatAvatar: document.getElementById('active-chat-avatar'),
messagesContainer: document.getElementById('messages-container'),
messageForm: document.getElementById('message-form'),
messageInput: document.getElementById('message-input'),
backToSidebarBtn: document.querySelector('.chat-header'),
sidebar: document.querySelector('.sidebar'),
appContainer: document.querySelector('.app-container'),
settingsModal: document.getElementById('settings-page'),
settingsIconBtn: document.getElementById('settings-sidebar-btn'),
saveSettingsBtn: document.getElementById('save-settings'),
inputWahaUrl: document.getElementById('settings-waha-url'),
inputSession: document.getElementById('settings-session'),
inputApiKey: document.getElementById('settings-api-key'),
inputBackgroundImage: document.getElementById('settings-background-image'),
inputBackgroundOpacity: document.getElementById('settings-background-opacity'),
loggedUserName: document.getElementById('pandora-username'),
chatBottomBar: document.getElementById('chat-bottom-bar'),
chatBottomBarBtn: document.getElementById('chat-bottom-bar-btn'),
chatInputPanel: document.getElementById('chat-input-panel'),
attachmentInput: document.getElementById('attachment-input'),
attachmentBtn: document.getElementById('attachment-btn'),
extraPages: document.querySelectorAll('.extra-page'),
desktopSidebarButtons: document.querySelectorAll("#desktop-aside button"),
contentUserName: document.querySelectorAll('[data-content="app-user"]'),
contentUserNumber: document.querySelectorAll('[data-content="app-user-number"]'),
resourceUserPic: document.querySelectorAll('[data-resource="app-user-image"]'),
valueUserStatus: document.querySelectorAll('[data-value="app-user-status"]'),
inputUserStatus: document.getElementById('profile-page-status-input'),
selectable: document.querySelectorAll('.selectable'),
};
export const ui = {
showExtraPage(pageId) {
elements.extraPages.forEach(page => {
if (page.id != pageId) {
page.style.display = "none";
} else {
page.style.display = "flex";
}
})
},
/**
* Switch view state when a contact chat is opened or closed
*/
toggleChatState(hasActive) {
if (hasActive) {
elements.noChatState.classList.add('hidden');
elements.activeChatContainer.classList.remove('hidden');
} else {
elements.noChatState.classList.remove('hidden');
elements.activeChatContainer.classList.add('hidden');
}
},
/**
* Scroll message list automatically to bottom
*/
scrollToBottom() {
elements.messagesContainer.scrollTop = elements.messagesContainer.scrollHeight;
},
/**
* Update connection status badge in sidebar footer
*/
updateConnectionStatus(isConnected, text) {
elements.backendStatusText.textContent = text;
if (isConnected) {
elements.apiStatusIndicator.style.backgroundColor = 'var(--online-color)';
elements.apiStatusIndicator.style.animation = 'pulse 1.8s infinite';
} else {
elements.apiStatusIndicator.style.backgroundColor = '#ef4444';
elements.apiStatusIndicator.style.animation = 'none';
}
},
async renderChatList(chats, activeChat, onChatSelect) {
elements.chatList.innerHTML = '';
chats.sort((a, b) => b.timestamp - a.timestamp);
if (chats.length === 0) {
elements.chatList.innerHTML = `<li class="loading-chats">No chats found</li>`;
return;
}
for (const chat of chats) {
const li = document.createElement('li');
li.className = `chat-item selectable ${activeChat && activeChat.id === chat.id ? 'active' : ''}`;
li.dataset.id = 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());
li.innerHTML = `
<div class="avatar">
<img
src=""
alt="${initials}"
data-chat-avatar="${chat.id}"
/>
</div>
<div class="chat-item-info">
<div class="chat-item-meta">
<span class="chat-item-name">${chat.name}</span>
<span class="chat-item-time">${timeStr}</span>
</div>
<div class="chat-item-preview">
<span class="chat-item-msg" data-chatid="${chat.id}">
${chat.lastMessage || 'No messages yet'}
</span>
${hasUnread ? `<span class="unread-badge">${chat.unreadCount}</span>` : ''}
</div>
</div>
`;
li.addEventListener('click', () => onChatSelect(chat));
elements.chatList.appendChild(li);
(async () => {
try {
const picture = await getChatPicture(chat.id);
const img = li.querySelector(`img[data-chat-avatar="${chat.id}"]`);
if (img) img.src = picture.url ? picture.url : '';
} catch (e) {
}
})();
}
},
async updateChatInChatList(msg) {
const chat = document.querySelector(`.chat-item[data-id="${msg.fromMe ? msg.to : msg.from}"]`);
if (chat) {
const messageItem = chat.querySelector('.chat-item-msg');
const time = chat.querySelector('.chat-item-time')
messageItem.innerText = msg.body || msg.text || 'Media message';
time.innerText = msg.timestamp ? formatTime(msg.timestamp) : Date.now();
const activeChatState = getCurrentChat();
if (!msg.fromMe && (!activeChatState || activeChatState.id !== (msg.fromMe ? msg.to : msg.from))) {
const unreadBadge = chat.querySelector('.unread-badge');
unreadBadge.innerText = (Number(unreadBadge.innerHTML) || 0) + 1;
}
}
},
/**
* Render chat message log inside chat view container
*/
async renderMessages(messages, activeChatName, userID, chatId) {
elements.messagesContainer.innerHTML = '';
if (messages.length === 0) {
elements.messagesContainer.innerHTML = '<div class="loading-chats">No messages. Say hello!</div>';
return;
}
const loadMore = document.createElement("button");
loadMore.classList.add("load-more-btn");
loadMore.innerText = "Load more";
loadMore.addEventListener('click', () => {
this.loadMoreMessages(chatId, userID);
});
elements.messagesContainer.appendChild(loadMore);
for (const msg of messages) {
this.appendSingleMessage(msg, userID, chatId);
}
this.scrollToBottom();
},
async loadMoreMessages(chatId, userId) {
const oldest = document.querySelector('.message-group:first-of-type');
const oldestTimestamp = oldest.dataset.timestamp;
const oldestId = oldest.id;
console.log(oldest)
console.log(oldestId)
console.log(oldestTimestamp)
const loadMoreButton = document.querySelector('.load-more-btn');
loadMoreButton.removeEventListener('click', this.loadMoreMessages);
const msgs = await getMoreChatMessages(chatId, oldestTimestamp, oldestId);
msgs.shift();
msgs.forEach(async msg => {
loadMoreButton.after(this.generateMessage(msg, userId, chatId));
});
loadMoreButton.addEventListener('click', this.loadMoreMessages);
},
/**
* Append a single message (used for optimistic updates immediately upon sending)
*/
appendSingleMessage(msg, userID, chatId, isLocal = false) {
elements.messagesContainer.appendChild(this.generateMessage(msg, userID, chatId, isLocal))
},
generateTempMessageLink(msg) {
const a = document.createElement('a');
a.target = "_blank";
a.href = msg.media.url;
if (msg._data?.mimetype?.startsWith('image/')) {
const img = document.createElement('img');
img.classList.add('message-image-attachement');
img.src = msg.media.url;
a.appendChild(img);
} else {
a.textContent = msg.media.filename || "Download file";
a.download = msg.media.filename;
}
return a;
},
generateMessage(msg, userID, chatId, isLocal = false) {
const isOutgoing = msg.fromMe || msg.sender === 'me';
function getPrevMessageElem() {
return elements.messagesContainer.lastElementChild;
}
const prevMsgEl = getPrevMessageElem();
const groupDiv = document.createElement('div');
groupDiv.className = `message-group selectable ${isOutgoing ? 'outgoing' : 'incoming'}`;
groupDiv.id = normalizeId(msg._serialized ? msg : msg.id);
groupDiv.dataset.timestamp = msg.timestamp;
groupDiv.dataset.from = msg.participant || msg.from;
const senderName = isOutgoing ? userID : (msg._data.notifyName || msg.from);
const timeStr = formatTime(msg.timestamp || new Date());
let statusCheck = '';
if (isOutgoing) {
if (msg.status === 'read') {
statusCheck = '<span class="mif-done_all" style="color: var(--online-color); width:14px; height:14px;"></span>';
} else if (msg.status === 'delivered') {
statusCheck = '<span class="mif-done" style="width:14px; height:14px;"></span>';
} else if (msg.status === 'sending') {
statusCheck = '<span class="mif-earth" style="width:14px; height:14px;"></span>';
} else {
statusCheck = '<span class="mif-done" style="width:14px; height:14px;"></span>';
}
}
const bubble = document.createElement('div');
bubble.className = 'message-bubble';
let prevUid;
if (msg.participant) {
prevUid = msg.participant;
} else {
prevUid = msg.from;
}
if (!isOutgoing && (!prevMsgEl || prevUid !== prevMsgEl.dataset.from)) {
const senderEl = document.createElement('span');
senderEl.className = 'message-sender';
senderEl.textContent = senderName;
bubble.appendChild(senderEl);
}
const contentEl = document.createElement('div');
contentEl.classList.add('message-content');
const textEl = document.createElement('div');
textEl.innerHTML = msg.body || msg.text || "";
contentEl.appendChild(textEl);
bubble.appendChild(contentEl);
if (msg.hasMedia) {
let a;
if (isLocal) {
a = this.generateTempMessageLink(msg);
} else {
a = document.createElement('a');
a.innerText = `[Request media]`;
a.target = "_blank";
const clickListener = async (e) => {
a.removeEventListener('click', clickListener);
a.innerText = `[Downloading]`;
const mediaMsg = msg.media ? msg : await getMessage(chatId, normalizeId(msg._serialized ? msg : msg.id), true);
if (!mediaMsg || !mediaMsg?.media.url) {
a.addEventListener('click', clickListener);
a.innerText = `[Error, click to try again]`
return;
}
const url = new URL(mediaMsg.media.url);
const reqID = url.pathname.split('/').filter(Boolean).pop();
const { blob, filename } = await getMedia(reqID);
const objectUrl = URL.createObjectURL(blob);
e.target.href = objectUrl;
if (blob.type.startsWith('image/')) {
a.textContent = "";
const img = document.createElement('img');
img.classList.add('message-image-attachement');
img.src = objectUrl;
bubble.after(img);
bubble.querySelector('.message-content').remove();
} else {
e.target.textContent = filename || `Download ${mediaMsg.media.filename}`;
}
}
a.addEventListener('click', clickListener);
}
contentEl.appendChild(a);
if (!isLocal && msg._data?.mimetype?.startsWith('image/')) {
a.click();
}
}
const meta = document.createElement('div');
meta.className = 'message-meta';
meta.innerHTML = `<span>${timeStr}</span>${statusCheck}`;
bubble.appendChild(meta);
if (isOutgoing) {
if (prevMsgEl && prevMsgEl.classList.contains('outgoing')) {
groupDiv.classList.add('same-sender');
} else {
groupDiv.appendChild(document.createElement('div')).className = 'message-indicator';
}
} else if (msg.participant) {
if (!prevMsgEl || prevUid !== prevMsgEl.dataset.from) {
groupDiv.appendChild(document.createElement('div')).className = 'message-indicator';
} else groupDiv.classList.add('same-sender');
} else {
if (!prevMsgEl || prevUid !== prevMsgEl.dataset.from) {
groupDiv.appendChild(document.createElement('div')).className = 'message-indicator';
} else groupDiv.classList.add('same-sender');
}
groupDiv.appendChild(bubble);
return groupDiv;
},
updateMessage(originalMsgId, generatedMsg) {
const originalMsg = document.querySelector(`#${originalMsgId}`);
if (originalMsg) {
originalMsg.replaceWith(generatedMsg)
}
},
updateMessageTick(id, status) {
let statusCheck;
if (status === 'read') {
statusCheck = '<span class="mif-done_all" style="color: var(--online-color); width:14px; height:14px;"></span>';
} else if (status === 'delivered') {
statusCheck = '<span class="mif-done" style="width:14px; height:14px;"></span>';
} else if (status === 'sending') {
statusCheck = '<span class="mif-earth" style="width:14px; height:14px;"></span>';
} else {
statusCheck = '<span class="mif-done" style="width:14px; height:14px;"></span>';
}
document.getElementById(id).querySelector('.message-meta').outerHTML = statusCheck;
},
toggleChatBottomBar() {
elements.chatBottomBar.classList.toggle("collapsed");
},
removeChatMessage(msgId) {
const message = document.getElementById(msgId);
if (message) message.remove();
}
};

View file

@ -79,3 +79,45 @@ export function compensateMessageOrdering(messages) {
// Clean up temporary property
return msgs.map(({ _time, ...m }) => m);
}
export function getBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result.split(",")[1]);
};
reader.onerror = (e) => {
console.error("Error", e);
reject(e);
};
reader.onabort = () => {
reject(new Error("Aborted"));
};
reader.readAsDataURL(file);
});
}
/**
* Normalize a WhatsApp ID (chatId, messageId, etc.) to its string representation
* @param {string|object} raw
* @returns {string|null}
*/
export function normalizeId(raw) {
if (!raw) return null;
if (typeof raw === 'object') {
return raw._serialized || raw.user || JSON.stringify(raw);
}
return raw;
}
export function debounce(func, delay) {
let timeoutId;
return function() {
clearTimeout(timeoutId);
timeoutId = setTimeout(func, delay);
};
}

186
js/waha.js Normal file
View file

@ -0,0 +1,186 @@
import { config } from "./config.js";
import { getBase64 } from "./utils.js";
async function request(path, options = {}) {
const url = `${config.wahaUrl}${path}`;
const headers = {
'Content-Type': 'application/json',
'accept': '*/*',
...options.headers
};
if (config.apiKey) {
headers['X-Api-Key'] = config.apiKey;
}
// console.log(`[WAHA] ${options.method || 'GET'} ${url}`, options.body ? JSON.parse(options.body) : '');
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
let errorDetail = '';
try {
const errBody = await response.json();
errorDetail = JSON.stringify(errBody);
console.error(`[WAHA] Error response body:`, errBody);
} catch (_) {
errorDetail = await response.text().catch(() => '');
}
throw new Error(`WAHA API returned ${response.status}: ${response.statusText}${errorDetail}`);
}
return response.json();
}
async function downloadFile(path, options = {}) {
const url = `${config.wahaUrl}${path}`;
const headers = {
'Content-Type': options.headers?.['Content-Type'] ?? 'application/json',
'accept': '*/*',
...options.headers
};
if (config.apiKey) headers['X-Api-Key'] = config.apiKey;
// console.log(`[WAHA] ${options.method || 'GET'} ${url}`);
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
let errorDetail = '';
try {
errorDetail = JSON.stringify(await response.json());
} catch (_) {
errorDetail = await response.text().catch(() => '');
}
throw new Error(`WAHA API returned ${response.status}: ${response.statusText}${errorDetail}`);
}
const blob = await response.blob();
let filename = 'download';
const cd = response.headers.get('content-disposition');
if (cd) {
const m = cd.match(/filename\*=UTF-8''([^;]+)|filename="?([^"]+)"?/i);
filename = decodeURIComponent(m?.[1] || m?.[2] || filename);
}
return { blob, filename };
}
export const waha = {
async getVersion() {
return await request('/api/version');
},
async getChats() {
const data = await request(`/api/${config.session}/chats`);
return data.map(chat => {
let chatId = chat.id;
if (chatId && typeof chatId === "object") {
chatId = chatId._serialized || chatId.user || JSON.stringify(chatId);
}
return {
id: chatId || chat.chatId || chat.name,
name: chat.name || "Unknown Contact",
unreadCount: chat.unreadCount || 0,
lastMessage: chat.lastMessage?.body || chat.lastMessageText || "Click to open chat",
timestamp: chat.lastMessage?.timestamp || new Date()
};
});
},
async getChatMessages(chatId, beforeTimestamp) {
console.log(`/api/${config.session}/chats/${chatId}/messages?downloadMedia=false&limit=40&sortBy=timestamp${beforeTimestamp ? `&filter.timestamp.gte=${beforeTimestamp}` : "" }`);
return request(`/api/${config.session}/chats/${chatId}/messages?downloadMedia=false&limit=40${beforeTimestamp ? `&filter.timestamp.lte=${beforeTimestamp}` : "" }`);
},
async getSingleChatMessage(chatId, messageId, downladMedia) {
return request(`/api/${config.session}/chats/${chatId}/messages/${messageId}?downloadMedia=${downladMedia}`);
},
async getChatPicture(chatId) {
return request(`/api/${config.session}/chats/${chatId}/picture`);
},
async getUser(chatId) {
return request(`/api/${config.session}/contacts/${chatId}`);
},
async getUserAbout(chatId) {
return request(`/api/contacts/about?contactId=${chatId}&session=${config.session}`);
},
async readChat(chatId) {
return request('/api/sendSeen', {
method: 'POST',
body: JSON.stringify({ chatId, session: config.session })
});
},
async downloadMedia(file) {
const { blob, filename } = await downloadFile(`/api/files/${config.session}/${file}`);
return { blob, filename };
},
async getMyInfo() {
return request(`/api/sessions/${config.session}/me`);
},
async startTyping(chatId) {
return request('/api/startTyping', {
method: 'POST',
body: JSON.stringify({ chatId, session: config.session })
});
},
async stopTyping(chatId) {
return request('/api/stopTyping', {
method: 'POST',
body: JSON.stringify({ chatId, session: config.session })
});
},
async sendTextMessage(chatId, text) {
return request('/api/sendText', {
method: 'POST',
body: JSON.stringify({
chatId,
text,
session: config.session
})
});
},
async setStatus(text) {
return request(`/api/${config.session}/profile/status`, {
method: 'PUT',
body: JSON.stringify({
status: text
})
});
},
async sendFileMessage(chatId, file) {
const fileBase64 = await getBase64(file);
const body = {
method: 'POST',
body: JSON.stringify({
chatId,
file: {
mimetype: file.type,
filename: file.name,
data: fileBase64
},
session: config.session
})
};
let endpoint = "/api/sendFile";
if (file.type.startsWith('image/')) endpoint = '/api/sendImage';
if (file.type.startsWith('video/')) endpoint = '/api/sendVideo';
const result = await request(endpoint, body);
return result;
}
};

View file

@ -1,4 +1,5 @@
import { config } from "./config.js";
import { isOnline, updateOnlineStatus } from "./storage.js";
let socket = null;
let reconnectTimer = null;
@ -6,6 +7,7 @@ let currentOnMessageCallback = null;
export const websocket = {
connect(onMessageCallback) {
if (!isOnline) return;
currentOnMessageCallback = onMessageCallback;
this.disconnect(false);
@ -21,7 +23,7 @@ export const websocket = {
const apiKey = config.apiKey;
const session = config.session;
const events = ['session.status', 'message', 'message.any'];
const events = ['session.status', 'message.any'];
const queryParams = new URLSearchParams();
if (apiKey) {
@ -64,6 +66,7 @@ export const websocket = {
socket = null;
reconnectTimer = setTimeout(() => {
updateOnlineStatus();
this.connect(currentOnMessageCallback);
}, 5000);
};

116
loading.css Normal file
View file

@ -0,0 +1,116 @@
.loading-animation-wrapper {
width: 200px;
height: 40px;
margin: 10px auto 0 auto;
padding-top: 4px;
}
.loading-animation-wrapper .caption {
margin: 0;
padding: 0;
text-align: center;
color: #fff;
font: 12px/1.4em Arial;
}
.loading-animation-wrapper .dot {
position: relative;
display: inline-block;
width: 10px;
height: 10px;
background: #fff;
-moz-border-radius: 100%;
-webkit-border-radius: 100%;
border-radius: 100%;
-moz-animation: movingdot 4s infinite;
-webkit-animation: movingdot 4s infinite;
animation: movingdot 4s infinite;
-moz-animation-timing-function: cubic-bezier(0.03, 0.615, 0.995, 0.415);
-webkit-animation-timing-function: cubic-bezier(0.03, 0.615, 0.995, 0.415);
animation-timing-function: cubic-bezier(0.03, 0.615, 0.995, 0.415);
-moz-animation-fill-mode: both;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
}
.loading-animation-wrapper .dot:nth-child(1) {
-moz-animation-delay: 1s;
-webkit-animation-delay: 1s;
animation-delay: 1s;
}
.loading-animation-wrapper .dot:nth-child(2) {
-moz-animation-delay: 0.9s;
-webkit-animation-delay: 0.9s;
animation-delay: 0.9s;
}
.loading-animation-wrapper .dot:nth-child(3) {
-moz-animation-delay: 0.8s;
-webkit-animation-delay: 0.8s;
animation-delay: 0.8s;
}
.loading-animation-wrapper .dot:nth-child(4) {
-moz-animation-delay: 0.7s;
-webkit-animation-delay: 0.7s;
animation-delay: 0.7s;
}
.loading-animation-wrapper .dot:nth-child(5) {
-moz-animation-delay: 0.6s;
-webkit-animation-delay: 0.6s;
animation-delay: 0.6s;
}
@-moz-keyframes movingdot {
0% {
-moz-transform: translateX(-30px);
transform: translateX(-30px);
opacity: 0;
}
25% {
opacity: 1;
}
50% {
-moz-transform: translateX(200px);
transform: translateX(200px);
opacity: 0;
}
100% {
opacity: 0;
}
}
@-webkit-keyframes movingdot {
0% {
-webkit-transform: translateX(-30px);
transform: translateX(-30px);
opacity: 0;
}
25% {
opacity: 1;
}
50% {
-webkit-transform: translateX(200px);
transform: translateX(200px);
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes movingdot {
0% {
-moz-transform: translateX(-30px);
-ms-transform: translateX(-30px);
-webkit-transform: translateX(-30px);
transform: translateX(-30px);
opacity: 0;
}
25% {
opacity: 1;
}
50% {
-moz-transform: translateX(200px);
-ms-transform: translateX(200px);
-webkit-transform: translateX(200px);
transform: translateX(200px);
opacity: 0;
}
100% {
opacity: 0;
}
}

23
manifest.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "PANDORA chat",
"short_name": "PANDORA",
"theme_color": "#ffffff",
"background_color": "#102457",
"display": "standalone",
"orientation": "any",
"scope": "./",
"start_url": "./",
"description": "Internet messaging",
"icons": [
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

BIN
message-long.ogg Normal file

Binary file not shown.

BIN
message.ogg Normal file

Binary file not shown.

1
metroicons.css Normal file

File diff suppressed because one or more lines are too long

1036
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,19 +0,0 @@
{
"name": "pandora",
"version": "1.0.0",
"type": "module",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"axios": "^1.18.1",
"dotenv": "^17.4.2",
"express": "^5.2.1"
}
}

View file

@ -1,263 +0,0 @@
import { config } from "./config.js";
import { waha } from "./waha.js";
import { ui, elements } from "./ui.js";
import { websocket } from "./websocket.js";
import { compensateMessageOrdering, formatTime } from "./utils.js";
let chatsState = [];
let activeChatState = null;
// Initialize app when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
setupEventListeners();
fetchChats();
checkWahaStatus();
initWebSocket();
});
function setupEventListeners() {
elements.refreshChatsBtn.addEventListener('click', () => {
elements.refreshChatsBtn.classList.add('spinning');
fetchChats().finally(() => {
setTimeout(() => {
elements.refreshChatsBtn.classList.remove('spinning');
}, 600);
});
});
elements.chatSearch.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const filtered = chatsState.filter(chat =>
chat.name.toLowerCase().includes(query)
);
ui.renderChatList(filtered, activeChatState, selectChat);
});
elements.messageForm.addEventListener('submit', (e) => {
e.preventDefault();
sendMessage();
});
elements.backToSidebarBtn.addEventListener('click', () => {
elements.sidebar.classList.remove('hidden');
elements.activeChatContainer.classList.add('hidden');
});
elements.settingsIconBtn.addEventListener('click', openSettings);
elements.closeSettingsBtn.addEventListener('click', () => ui.toggleModal(false));
elements.cancelSettingsBtn.addEventListener('click', () => ui.toggleModal(false));
elements.saveSettingsBtn.addEventListener('click', saveSettings);
}
function initWebSocket() {
websocket.connect((data) => {
console.log('[WS] Received event:', data.event, data);
const ev = data.event;
if (ev === 'message' || ev === 'message.any' || ev === 'message.ack') {
handleIncomingMessage(data.payload);
}
});
}
function normalizeChatId(raw) {
if (!raw) return null;
if (typeof raw === 'object') {
return raw._serialized || raw.user || JSON.stringify(raw);
}
return raw;
}
function handleIncomingMessage(msg) {
if (!msg) return;
console.log('[WS] handleIncomingMessage payload:', msg);
const rawChatId = msg.chatId || msg.from || (msg.chat && msg.chat.id);
const msgChatId = normalizeChatId(rawChatId);
if (!msgChatId) {
console.warn('[WS] Could not resolve chatId from payload:', msg);
return;
}
console.log('[WS] Resolved msgChatId:', msgChatId, '| activeChatState:', activeChatState?.id);
if (activeChatState && activeChatState.id === msgChatId) {
const msgId = normalizeChatId(msg.id) || msg.id;
const exists = document.getElementById(msgId);
if (!exists) {
ui.appendSingleMessage({ ...msg, chatId: msgChatId }, activeChatState.name);
ui.scrollToBottom();
}
}
const chatIndex = chatsState.findIndex(c => c.id === msgChatId);
if (chatIndex !== -1) {
const chat = chatsState[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() {
const userInfo = await waha.getMyInfo();
elements.loggedUserName.textContent = userInfo.pushName;
elements.userIcon.innerText = userInfo.pushName[0];
elements.chatsLoader.classList.remove('hidden');
try {
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: #f87171; text-align: center; padding: 20px;">
<p>Connection to WAHA failed.</p>
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 8px;">
Ensure WAHA server is running and CORS is enabled, or click Settings to configure.
</p>
</li>
`;
} finally {
elements.chatsLoader.classList.add('hidden');
}
}
async function selectChat(chat) {
activeChatState = chat;
chat.unreadCount = 0;
ui.renderChatList(chatsState, activeChatState, selectChat);
ui.toggleChatState(true);
elements.activeChatName.textContent = chat.name;
elements.activeChatAvatar.textContent = chat.name ? chat.name.substring(0, 1).toUpperCase() : '?';
elements.messagesContainer.innerHTML = '<div class="loading-chats"><div class="spinner"></div><span>Loading messages...</span></div>';
if (window.innerWidth <= 768) {
elements.sidebar.classList.add('hidden');
}
try {
const rawMessages = await waha.getChatMessages(chat.id);
const processedMessages = compensateMessageOrdering(rawMessages);
ui.renderMessages(processedMessages, chat.name);
} catch (error) {
console.error('Failed to load messages:', error);
elements.messagesContainer.innerHTML = '<div class="loading-chats">Error loading messages</div>';
}
}
async function sendMessage() {
const text = elements.messageInput.value.trim();
if (!text || !activeChatState) return;
elements.messageInput.value = '';
const tempMsg = {
id: 'temp-' + Date.now(),
body: text,
fromMe: true,
sender: 'me',
timestamp: new Date().toISOString(),
status: 'sending'
};
ui.appendSingleMessage(tempMsg, activeChatState.name);
ui.scrollToBottom();
try {
try {
await waha.startTyping(activeChatState.id);
const delay = Math.min(4000, Math.max(1000, text.length * 50));
await new Promise(resolve => setTimeout(resolve, delay));
} catch (e) {
console.warn('Presence start failed:', e);
}
try {
await waha.stopTyping(activeChatState.id);
} catch (e) {
console.warn('Presence stop failed:', e);
}
try {
if (!activeChatState.id.endsWith('@lid')) {
await waha.readChat(activeChatState.id);
}
} catch (e) {
console.warn('readChat failed (non-fatal):', e.message);
}
const responseData = await waha.sendTextMessage(activeChatState.id, text);
const tempBubble = document.getElementById(tempMsg.id);
if (tempBubble) {
if (responseData && responseData.id) {
tempBubble.id = responseData.id;
}
const meta = tempBubble.querySelector('.message-meta');
meta.innerHTML = `<span>${formatTime(new Date())}</span><i data-lucide="check" style="width:14px; height:14px;"></i>`;
lucide.createIcons();
}
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);
if (tempBubble) {
const meta = tempBubble.querySelector('.message-meta');
meta.innerHTML = `<span style="color: #ef4444;">Failed to send</span>`;
}
}
}
function openSettings() {
elements.inputWahaUrl.value = config.wahaUrl;
elements.inputSession.value = config.session;
elements.inputApiKey.value = config.apiKey;
ui.toggleModal(true);
}
function saveSettings() {
config.save(
elements.inputWahaUrl.value,
elements.inputSession.value,
elements.inputApiKey.value
);
ui.toggleModal(false);
fetchChats();
checkWahaStatus();
initWebSocket();
}
async function checkWahaStatus() {
try {
const data = await waha.getVersion();
ui.updateConnectionStatus(true, `WAHA Connected: v${data.version || 'OK'}`);
} catch (e) {
ui.updateConnectionStatus(false, 'WAHA Server Offline');
}
}

View file

@ -1,147 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pandora</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<script src="https://unpkg.com/lucide@latest"></script>
</head>
<body>
<div class="app-container">
<!-- Sidebar -->
<aside class="sidebar">
<header class="sidebar-header">
<div class="user-profile">
<div id="pandora-user-icon" class="avatar user-avatar">P</div>
<div class="user-info">
<h3 id="pandora-username">Pandora User</h3>
<span class="status-indicator online">Online</span>
</div>
</div>
<div class="header-actions">
<button class="icon-btn" title="New Chat"><i data-lucide="message-square-plus"></i></button>
<button class="icon-btn" id="refresh-chats-btn" title="Refresh Chats"><i data-lucide="refresh-cw"></i></button>
<button class="icon-btn" title="Settings"><i data-lucide="settings"></i></button>
</div>
</header>
<div class="search-container">
<div class="search-wrapper">
<i data-lucide="search" class="search-icon"></i>
<input type="text" id="chat-search" placeholder="Search or start new chat...">
</div>
</div>
<div class="chat-list-container">
<div class="chat-list-header">
<span>Recent Chats</span>
<span class="badge" id="chat-count">0</span>
</div>
<div class="loading-chats" id="chats-loader">
<div class="spinner"></div>
<span>Loading chats...</span>
</div>
<ul class="chat-list" id="chat-list">
<!-- Chats will be dynamically injected here -->
</ul>
</div>
<div class="sidebar-footer">
<div class="api-status-badge">
<span class="pulse-dot"></span>
<span id="backend-status-text">Backend connected</span>
</div>
</div>
</aside>
<!-- Main Chat Area -->
<main class="chat-area">
<!-- No Chat Selected State -->
<div class="no-chat-state" id="no-chat-state">
<div class="empty-state-content">
<div class="empty-state-icon">
<i data-lucide="message-square"></i>
</div>
<h2>Welcome to Pandora Chat</h2>
<p>Select a contact from the sidebar to view the conversation or start a new chat. Your chats are synced directly with the WAHA API backend.</p>
</div>
</div>
<!-- Active Chat State -->
<div class="active-chat-container hidden" id="active-chat-container">
<header class="chat-header">
<div class="active-contact-info">
<button class="icon-btn back-btn" id="back-to-sidebar"><i data-lucide="arrow-left"></i></button>
<div class="avatar active-avatar" id="active-chat-avatar">C</div>
<div>
<h3 id="active-chat-name">Contact Name</h3>
<span class="contact-status" id="active-chat-status">Active now</span>
</div>
</div>
<div class="chat-actions">
<button class="icon-btn" title="Search in conversation"><i data-lucide="search"></i></button>
<button class="icon-btn" title="Call"><i data-lucide="phone"></i></button>
<button class="icon-btn" title="Video Call"><i data-lucide="video"></i></button>
<button class="icon-btn" title="More Options"><i data-lucide="more-vertical"></i></button>
</div>
</header>
<!-- Messages Area -->
<div class="messages-container" id="messages-container">
<!-- Messages will be dynamically injected here -->
</div>
<!-- Input Panel -->
<footer class="chat-input-panel">
<div class="input-actions-left">
<button class="icon-btn" title="Attach file"><i data-lucide="paperclip"></i></button>
<button class="icon-btn" title="Add emoji"><i data-lucide="smile"></i></button>
</div>
<form class="input-form" id="message-form">
<input type="text" id="message-input" placeholder="Type a message..." autocomplete="off">
<button type="submit" class="send-btn" id="send-button">
<i data-lucide="send"></i>
</button>
</form>
</footer>
</div>
</main>
</div>
<!-- Settings Modal -->
<div class="modal-overlay hidden" id="settings-modal">
<div class="modal-content">
<header class="modal-header">
<h2>WAHA Connection Settings</h2>
<button class="icon-btn close-modal-btn" id="close-settings"><i data-lucide="x"></i></button>
</header>
<div class="modal-body">
<div class="form-group">
<label for="settings-waha-url">WAHA Server URL</label>
<input type="text" id="settings-waha-url" placeholder="http://localhost:3100">
</div>
<div class="form-group">
<label for="settings-session">Session ID</label>
<input type="text" id="settings-session" placeholder="session_01...">
</div>
<div class="form-group">
<label for="settings-api-key">API Key (X-API-KEY)</label>
<input type="password" id="settings-api-key" placeholder="Enter API Key">
</div>
<p class="settings-warning">⚠️ Note: Requests are sent directly from your browser to the WAHA server. Make sure CORS is enabled on your WAHA instance.</p>
</div>
<footer class="modal-footer">
<button class="btn secondary-btn" id="cancel-settings">Cancel</button>
<button class="btn primary-btn" id="save-settings">Save Config</button>
</footer>
</div>
</div>
<!-- Client-side script loaded as ES Module -->
<script type="module" src="app.js"></script>
</body>
</html>

View file

@ -1,198 +0,0 @@
import { formatTime } from "./utils.js";
// DOM Selector Cache
export const elements = {
chatList: document.getElementById('chat-list'),
chatsLoader: document.getElementById('chats-loader'),
chatCountBadge: document.getElementById('chat-count'),
chatSearch: document.getElementById('chat-search'),
refreshChatsBtn: document.getElementById('refresh-chats-btn'),
backendStatusText: document.getElementById('backend-status-text'),
apiStatusIndicator: document.querySelector('.pulse-dot'),
noChatState: document.getElementById('no-chat-state'),
activeChatContainer: document.getElementById('active-chat-container'),
activeChatName: document.getElementById('active-chat-name'),
activeChatAvatar: document.getElementById('active-chat-avatar'),
messagesContainer: document.getElementById('messages-container'),
messageForm: document.getElementById('message-form'),
messageInput: document.getElementById('message-input'),
backToSidebarBtn: document.getElementById('back-to-sidebar'),
sidebar: document.querySelector('.sidebar'),
settingsModal: document.getElementById('settings-modal'),
settingsIconBtn: document.querySelector('.header-actions button[title="Settings"]'),
closeSettingsBtn: document.getElementById('close-settings'),
cancelSettingsBtn: document.getElementById('cancel-settings'),
saveSettingsBtn: document.getElementById('save-settings'),
inputWahaUrl: document.getElementById('settings-waha-url'),
inputSession: document.getElementById('settings-session'),
inputApiKey: document.getElementById('settings-api-key'),
loggedUserName: document.getElementById('pandora-username'),
userIcon: document.getElementById('pandora-user-icon')
};
export const ui = {
/**
* Show or hide Settings connection modal
*/
toggleModal(show) {
if (show) {
elements.settingsModal.classList.remove('hidden');
} else {
elements.settingsModal.classList.add('hidden');
}
},
/**
* Switch view state when a contact chat is opened or closed
*/
toggleChatState(hasActive) {
if (hasActive) {
elements.noChatState.classList.add('hidden');
elements.activeChatContainer.classList.remove('hidden');
} else {
elements.noChatState.classList.remove('hidden');
elements.activeChatContainer.classList.add('hidden');
}
},
/**
* Scroll message list automatically to bottom
*/
scrollToBottom() {
elements.messagesContainer.scrollTop = elements.messagesContainer.scrollHeight;
},
/**
* Update connection status badge in sidebar footer
*/
updateConnectionStatus(isConnected, text) {
elements.backendStatusText.textContent = text;
if (isConnected) {
elements.apiStatusIndicator.style.backgroundColor = 'var(--online-color)';
elements.apiStatusIndicator.style.animation = 'pulse 1.8s infinite';
} else {
elements.apiStatusIndicator.style.backgroundColor = '#ef4444';
elements.apiStatusIndicator.style.animation = 'none';
}
},
/**
* Render sidebar contact chats list
*/
renderChatList(chats, activeChat, onChatSelect) {
elements.chatList.innerHTML = '';
elements.chatCountBadge.textContent = chats.length;
if (chats.length === 0) {
elements.chatList.innerHTML = `<li class="loading-chats">No chats found</li>`;
return;
}
chats.forEach(chat => {
const li = document.createElement('li');
li.className = `chat-item ${activeChat && activeChat.id === chat.id ? 'active' : ''}`;
li.dataset.id = 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());
li.innerHTML = `
<div class="avatar">${initials}</div>
<div class="chat-item-info">
<div class="chat-item-meta">
<span class="chat-item-name">${chat.name}</span>
<span class="chat-item-time">${timeStr}</span>
</div>
<div class="chat-item-preview">
<span class="chat-item-msg">${chat.lastMessage || 'No messages yet'}</span>
${hasUnread ? `<span class="unread-badge">${chat.unreadCount}</span>` : ''}
</div>
</div>
`;
li.addEventListener('click', () => onChatSelect(chat));
elements.chatList.appendChild(li);
});
},
/**
* Render chat message log inside chat view container
*/
renderMessages(messages, activeChatName) {
elements.messagesContainer.innerHTML = '';
if (messages.length === 0) {
elements.messagesContainer.innerHTML = '<div class="loading-chats">No messages. Say hello!</div>';
return;
}
messages.forEach(msg => {
const isOutgoing = msg.fromMe || msg.sender === 'me';
const groupDiv = document.createElement('div');
groupDiv.className = `message-group ${isOutgoing ? 'outgoing' : 'incoming'}`;
groupDiv.id = msg.id;
const senderName = isOutgoing ? 'Me' : (msg.senderName || activeChatName);
const timeStr = formatTime(msg.timestamp || new Date());
let statusCheck = '';
if (isOutgoing) {
if (msg.status === 'read') {
statusCheck = '<i data-lucide="check-check" style="color: var(--online-color); width:14px; height:14px;"></i>';
} else if (msg.status === 'delivered') {
statusCheck = '<i data-lucide="check-check" style="width:14px; height:14px;"></i>';
} else {
statusCheck = '<i data-lucide="check" style="width:14px; height:14px;"></i>';
}
}
groupDiv.innerHTML = `
${!isOutgoing ? `<span class="message-sender">${senderName}</span>` : ''}
<div class="message-bubble">
${msg.body || msg.text}
<div class="message-meta">
<span>${timeStr}</span>
${statusCheck}
</div>
</div>
`;
elements.messagesContainer.appendChild(groupDiv);
});
lucide.createIcons();
this.scrollToBottom();
},
/**
* Append a single message (used for optimistic updates immediately upon sending)
*/
appendSingleMessage(msg, activeChatName) {
if (elements.messagesContainer.querySelector('.loading-chats')) {
elements.messagesContainer.innerHTML = '';
}
const isOutgoing = msg.fromMe || msg.sender === 'me';
const groupDiv = document.createElement('div');
groupDiv.className = `message-group ${isOutgoing ? 'outgoing' : 'incoming'}`;
groupDiv.id = msg.id;
const senderName = isOutgoing ? 'Me' : activeChatName;
const timeStr = formatTime(msg.timestamp || new Date());
groupDiv.innerHTML = `
${!isOutgoing ? `<span class="message-sender">${senderName}</span>` : ''}
<div class="message-bubble">
${msg.body || msg.text}
<div class="message-meta">
<span>${timeStr}</span>
<i data-lucide="clock" style="width:12px; height:12px; opacity:0.6;"></i>
</div>
</div>
`;
elements.messagesContainer.appendChild(groupDiv);
lucide.createIcons();
}
};

View file

@ -1,92 +0,0 @@
import { config } from "./config.js";
async function request(path, options = {}) {
const url = `${config.wahaUrl}${path}`;
const headers = {
'Content-Type': 'application/json',
'accept': '*/*',
...options.headers
};
if (config.apiKey) {
headers['X-Api-Key'] = config.apiKey;
}
console.log(`[WAHA] ${options.method || 'GET'} ${url}`, options.body ? JSON.parse(options.body) : '');
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
let errorDetail = '';
try {
const errBody = await response.json();
errorDetail = JSON.stringify(errBody);
console.error(`[WAHA] Error response body:`, errBody);
} catch (_) {
errorDetail = await response.text().catch(() => '');
}
throw new Error(`WAHA API returned ${response.status}: ${response.statusText}${errorDetail}`);
}
return response.json();
}
export const waha = {
async getVersion() {
return request('/api/version');
},
async getChats() {
const data = await request(`/api/${config.session}/chats`);
return data.map(chat => {
let chatId = chat.id;
if (chatId && typeof chatId === "object") {
chatId = chatId._serialized || chatId.user || JSON.stringify(chatId);
}
return {
id: chatId || chat.chatId || chat.name,
name: chat.name || "Unknown Contact",
unreadCount: chat.unreadCount || 0,
lastMessage: chat.lastMessage?.body || chat.lastMessageText || "Click to open chat",
timestamp: chat.lastMessage?.timestamp || new Date()
};
});
},
async getChatMessages(chatId) {
return request(`/api/${config.session}/chats/${chatId}/messages?downloadMedia=false&limit=40`);
},
async readChat(chatId) {
return request('/api/sendSeen', {
method: 'POST',
body: JSON.stringify({ chatId, session: config.session })
});
},
async getMyInfo() {
return request(`/api/sessions/${config.session}/me`);
},
async startTyping(chatId) {
return request('/api/startTyping', {
method: 'POST',
body: JSON.stringify({ chatId, session: config.session })
});
},
async stopTyping(chatId) {
return request('/api/stopTyping', {
method: 'POST',
body: JSON.stringify({ chatId, session: config.session })
});
},
async sendTextMessage(chatId, text) {
return request('/api/sendText', {
method: 'POST',
body: JSON.stringify({
chatId,
text,
session: config.session
})
});
}
};

View file

@ -1,48 +1,142 @@
@font-face {
font-family: Selawik;
src: url(./fonts/selawk.ttf);
font-weight: normal;
}
@font-face {
font-family: Selawik;
src: url(./fonts/selawkl.ttf);
font-weight: 200;
}
@font-face {
font-family: Selawik;
src: url(./fonts/selawksl.ttf);
font-weight: 300;
}
@font-face {
font-family: Selawik;
src: url(./fonts/selawkb.ttf);
font-weight: bold;
}
@font-face {
font-family: Selawik;
src: url(./fonts/selawkb.ttf);
font-weight: 800;
}
:root {
--bg-main: #090d16;
--sidebar-bg: rgba(17, 24, 39, 0.75);
--chat-bg: rgba(15, 23, 42, 0.6);
--bg-main: black;
--bg-secondary: #242424;
--sidebar-bg: black;
--chat-bg: black;
--border-color: rgba(255, 255, 255, 0.08);
--border-hover: rgba(255, 255, 255, 0.15);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent: #6366f1;
--accent-gradient: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
--text-muted: rgb(134, 134, 134);
--accent: white;
--accent-gradient: black;
--accent-hover: #4f46e5;
--online-color: #10b981;
--bubble-incoming: rgba(30, 41, 59, 0.85);
--bubble-outgoing: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
--input-bg: rgba(15, 23, 42, 0.8);
--online-color: rgba(255, 255, 255, 0.6);
--bubble-incoming: #01abaa;
--bubble-outgoing: #016f6e;
--input-bg: black;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.1);
--glass-blur: blur(20px);
--high-box-shadow: 2px 7px 5px rgba(0, 0, 0, 0.3), 0px -4px 10px rgba(0, 0, 0, 0.3);
}
body.light {
--bg-main: white;
--bg-secondary: #F0F0F0;
--sidebar-bg: white;
--chat-bg: white;
--border-color: rgba(255, 255, 255, 0.08);
--border-hover: rgba(255, 255, 255, 0.15);
--text-primary: black;
--text-secondary: black;
--text-muted: rgb(134, 134, 134);
--accent: black; --accent-gradient: white; --accent-hover: #4f46e5; --online-color: rgba(0, 0, 000, 0.6);
--bubble-incoming: #01abaa;
--bubble-outgoing: #016f6e;
--input-bg: white; --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.1); --glass-blur: blur(20px);
--high-box-shadow: 2px 7px 5px rgba(0, 0, 0, 0.3), 0px -4px 10px rgba(0, 0, 0, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-family: Selawik, 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
user-select: none;
}
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
height: 100dvh;
overflow: hidden;
position: fixed;
top: 0;
left: 0;
}
body {
background-color: var(--bg-main);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
background-image:
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.12) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(168, 85, 247, 0.12) 0px, transparent 50%);
radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.12) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(168, 85, 247, 0.12) 0px, transparent 50%);
}
a {
color: var(--text-primary);
}
button {
background-color: var(--bg-main);
border: none;
color: var(--text-primary);
transition: .4s;
}
button:hover {
color: var(--text-secondary);
}
input {
background: var(--bg-secondary);
color: var(--accent);
border: none;
padding: 1em;
transition: .2s;
}
input:focus {
background-color: var(--text-primary);
color: var(--bg-main);
outline: none;
}
.app-container {
display: flex;
width: 100vw;
height: 100vh;
width: 100%;
height: 100%;
overflow: hidden;
backdrop-filter: var(--glass-blur);
position: relative;
}
.app-name {
font-weight: bold;
}
/* Sidebar Styling */
@ -57,11 +151,14 @@ body {
}
.sidebar-header {
padding: 20px;
padding: 20px 20px 0px 20px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
flex-direction: column;
align-items: self-start;
}
#pandora-username {
font-size: xx-large;
}
.user-profile {
@ -73,7 +170,6 @@ body {
.avatar {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
@ -86,6 +182,10 @@ body {
border: 1px solid rgba(255, 255, 255, 0.1);
}
.avatar img {
max-width: 100%;
}
.user-avatar {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}
@ -121,10 +221,9 @@ body {
.icon-btn {
width: 38px;
height: 38px;
border-radius: 10px;
border: 1px solid var(--border-color);
background: rgba(255, 255, 255, 0.03);
color: var(--text-secondary);
border: none;
background: var(--bg-main);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
@ -136,13 +235,16 @@ body {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
border-color: var(--border-hover);
transform: translateY(-1px);
}
.icon-btn:active {
transform: translateY(0);
}
.text-btn {
font-size: larger;
}
/* Search Bar */
.search-container {
padding: 16px 20px;
@ -165,19 +267,18 @@ body {
#chat-search {
width: 100%;
padding: 12px 14px 12px 42px;
border-radius: 10px;
border: 1px solid var(--border-color);
background: rgba(15, 23, 42, 0.6);
color: var(--text-primary);
padding: 12px;
border: none;
font-size: 0.9rem;
transition: all 0.25s ease;
background-color: var(--bg-secondary);
}
#chat-search:focus {
outline: none;
border-color: var(--accent);
background: rgba(15, 23, 42, 0.9);
background: white;
color: var(--bg-main);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
@ -223,19 +324,24 @@ body {
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.chat-item:hover {
background: rgba(255, 255, 255, 0.04);
.selected {
background: rgba(255, 255, 255, 0.04);
transform: scale(0.98) skewX(10deg);
filter: blur(.1px);
opacity: .6;
}
.chat-item.active {
background: rgba(99, 102, 241, 0.15);
border-color: rgba(99, 102, 241, 0.25);
.selectable:active {
background: rgba(255, 255, 255, 0.04);
transform: scale(0.98) skewX(10deg);
filter: blur(.1px);
opacity: .6;
}
.chat-item-info {
@ -274,16 +380,17 @@ body {
.chat-item-msg {
font-size: 0.82rem;
color: var(--text-secondary);
color: var(--accent);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 300;
flex: 1;
}
.chat-item .unread-badge {
background: var(--accent);
color: white;
color: var(--bg-main);
font-size: 0.7rem;
min-width: 18px;
height: 18px;
@ -319,13 +426,17 @@ body {
to { transform: rotate(360deg); }
}
/* Sidebar Footer */
.sidebar-footer {
padding: 14px 20px;
border-top: 1px solid var(--border-color);
background: rgba(10, 15, 26, 0.4);
/* Desktop aside */
#desktop-aside {
height: 100%;
background-color: black;
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: .2em;
}
/* Sidebar Footer */
.api-status-badge {
display: flex;
align-items: center;
@ -339,14 +450,14 @@ body {
height: 8px;
background-color: var(--online-color);
border-radius: 50%;
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
animation: pulse 1.8s infinite;
}
@keyframes pulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
}
70% {
transform: scale(1);
@ -385,15 +496,15 @@ body {
.empty-state-icon {
width: 80px;
height: 80px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
background: var(--bg-secondary);
/* border: 1px solid var(--border-color); */
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px auto;
color: var(--accent);
font-size: 2rem;
align-content: center;
}
.empty-state-content h2 {
@ -414,15 +525,48 @@ body {
display: flex;
flex-direction: column;
height: 100%;
position: relative;
overflow: hidden;
z-index: 0;
}
.active-chat-container::before {
content: "";
position: absolute;
inset: 0;
background-image: var(--background-image);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
opacity: var(--background-opacity);
pointer-events: none;
z-index: 0;
}
.active-chat-container > * {
position: relative;
z-index: 1;
}
.chat-header {
padding: 16px 24px;
border-bottom: 1px solid var(--border-color);
padding: .6em 1em;
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(15, 23, 42, 0.3);
position: relative;
}
.chat-header::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: -1;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.75),
rgba(0, 0, 0, 0.35),
transparent
);
}
.active-contact-info {
@ -435,12 +579,12 @@ body {
width: 42px;
height: 42px;
border-radius: 12px;
display: none;
}
.active-contact-info h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 2px;
font-weight: bold;
}
.contact-status {
@ -461,20 +605,35 @@ body {
.messages-container {
flex: 1;
overflow-y: auto;
padding: 24px;
padding: 1rem;
padding-bottom: .6rem;
display: flex;
flex-direction: column;
gap: 16px;
user-select: text;
background-image:
radial-gradient(circle at 10% 20%, rgba(99, 102, 241, 0.03) 0%, transparent 40%),
radial-gradient(circle at 90% 80%, rgba(168, 85, 247, 0.03) 0%, transparent 40%);
radial-gradient(circle at 10% 20%, rgba(99, 102, 241, 0.03) 0%, transparent 40%),
radial-gradient(circle at 90% 80%, rgba(168, 85, 247, 0.03) 0%, transparent 40%);
}
.load-more-btn {
margin: auto;
background-color: rgba(255, 255, 255, 0.1);
padding: .4em;
cursor: pointer;
box-shadow: var(--high-box-shadow);
}
.load-more-btn:hover {
background-color: var(--text-primary);
color: var(--bg-main);
}
.message-group {
margin-top: .8em;
display: flex;
flex-direction: column;
max-width: 65%;
gap: 4px;
transition: .1s;
}
.message-group.incoming {
@ -485,17 +644,28 @@ body {
align-self: flex-end;
}
.message-group.same-sender {
margin-top: .4em;
}
.message-sender {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
margin-left: 12px;
margin-bottom: 2px;
color: var(--text-primary);
width: 100%;
display: flex;
}
.message-indicator {
width: 0;
height: 0;
margin: 0px 10px;
}
.message-bubble {
padding: 12px 16px;
border-radius: 18px;
display: flex;
flex-direction: column;
padding: 8px 10px;
font-size: 0.92rem;
line-height: 1.5;
position: relative;
@ -506,14 +676,33 @@ body {
.message-group.incoming .message-bubble {
background-color: var(--bubble-incoming);
color: var(--text-primary);
border-top-left-radius: 4px;
border: 1px solid var(--border-color);
}
.message-group.outgoing .message-bubble {
background: var(--bubble-outgoing);
color: white;
border-top-right-radius: 4px;
}
.message-group.outgoing .message-indicator {
border-left: 10px solid transparent;
border-bottom: 10px solid var(--bubble-outgoing);
margin-left: auto;
}
.message-group.incoming .message-indicator {
border-right: 10px solid transparent;
border-bottom: 10px solid var(--bubble-incoming);
}
.message-content {
display: flex;
flex-direction: column;
gap: .4em;
}
.message-content * {
user-select: text;
}
.message-meta {
@ -522,22 +711,56 @@ body {
justify-content: flex-end;
gap: 4px;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.65);
margin-top: 4px;
}
.message-group.incoming .message-meta {
color: var(--text-muted);
.message-image-attachement {
max-width: 100%;
max-height: 10em;
}
/* Chat Input Panel */
.chat-input-panel {
padding: 20px 24px;
background: var(--input-bg);
border-top: 1px solid var(--border-color);
padding: .8rem;
display: flex;
align-items: center;
gap: 16px;
background-color: var(--bg-main);
}
.alternate-panel {
background-color: var(--bg-secondary);
padding: .4em;
display: flex;
justify-content: center;
align-items: center;
gap: .6em;
position: absolute;
left: 0;
right: 0;
bottom: 0;
transform: translateY(0);
transition: transform .3s;
flex: 1;
}
.alternate-panel.collapsed {
transform: translateY(100%);
}
.alternate-panel button, .alternate-panel input {
border: medium solid var(--text-primary);
border-radius: 100%;
background-color: transparent;
}
.alternate-panel button:hover, .alternate-panel input:hover {
border-color: var(--bg-primary);
background-color: var(--text-primary);
color: var(--bg-primary);
}
.chat-input-panel button {
background-color: transparent;
}
.input-actions-left {
@ -550,30 +773,30 @@ body {
display: flex;
gap: 12px;
align-items: center;
min-width: 0;
}
#message-input {
flex: 1;
padding: 12px 18px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border-color);
border-radius: 12px;
color: var(--text-primary);
background-color: var(--text-primary);
border: medium solid var(--border-color);
color: var(--bg-main);
font-size: 0.95rem;
transition: all 0.2s ease;
min-width: 0;
}
#message-input:focus {
color: var(--text-primary);
outline: none;
border-color: var(--accent);
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.1);
}
.send-btn {
width: 44px;
height: 44px;
border-radius: 12px;
background: var(--accent-gradient);
border: none;
color: white;
@ -585,11 +808,6 @@ body {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.send-btn:hover {
transform: scale(1.05) translateY(-1px);
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4);
}
.send-btn:active {
transform: scale(0.98) translateY(0);
}
@ -611,7 +829,7 @@ body {
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.08);
border-radius: 10px;
border-radius: 0px;
}
::-webkit-scrollbar-thumb:hover {
@ -623,61 +841,169 @@ body {
display: none !important;
}
/* Responsive design */
/* Extra Pages */
.extra-page {
display: none;
width: 100%;
}
.extra-page-content {
padding: 24px;
color: var(--text-secondary);
}
#chat-page {
display: flex;
}
#profile-page {
flex-direction: column;
background-color: var(--bg-main);
}
#profile-page .content {
padding: 1.4rem;
display: flex;
gap: 1em;
}
#profile-page-user-info {
flex: 1;
}
#profile-page h2 {
padding: 1.4rem;
font-size: 5rem;
text-wrap-mode: nowrap;
overflow: hidden;
font-weight: 200;
}
#profile-page-picture {
width: 100%;
max-width: 10%;
height: auto;
aspect-ratio: 1/1;
}
#profile-page-status-input {
width: 100%;
background-color: var(--bg-main);
font-size: xx-large;
border-bottom: medium solid transparent;
padding: 0;
}
#profile-page-status-input:focus {
color: var(--text-primary);
border-bottom-color: var(--text-primary);
}
@media (max-width: 768px) {
.app-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: row;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
/* Hide scrollbars */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.app-container::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.sidebar {
width: 100%;
position: absolute;
left: 0;
top: 0;
flex: 0 0 100%;
position: relative;
left: auto;
top: auto;
height: 100%;
z-index: 10;
}
.sidebar.hidden {
transform: translateX(-100%);
display: flex !important; /* overrides standard .hidden */
z-index: 1;
scroll-snap-align: start;
scroll-snap-stop: always;
}
.chat-area {
width: 100%;
flex: 0 0 100%;
position: relative;
left: auto;
top: auto;
height: 100%;
z-index: 1;
scroll-snap-align: start;
scroll-snap-stop: always;
}
.extra-page {
display: block;
width: 100%;
flex: 0 0 100%;
position: relative;
left: auto;
top: auto;
height: 100%;
z-index: 1;
scroll-snap-align: start;
scroll-snap-stop: always;
background-color: var(--bg-main);
overflow-y: auto;
}
.back-btn {
display: flex;
margin-right: 8px;
margin-right: 12px;
}
.message-group {
max-width: 90%;
}
.message-image-attachement {
max-height: 20em;
}
#desktop-aside {
display: none;
}
#profile-page .content {
flex-direction: column;
}
#profile-page-picture {
max-width: 50%;
margin: auto;
}
#profile-page-user-number {
text-align: center;
}
}
/* Modal Styling */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
transition: all 0.3s ease;
}
.modal-content {
background: rgba(30, 41, 59, 0.85);
border: 1px solid var(--border-color);
border-radius: 16px;
width: 90%;
max-width: 480px;
width: 100%;
background: var(--bg-main);
padding: 24px;
box-shadow: var(--shadow-lg);
backdrop-filter: var(--glass-blur);
display: flex;
flex-direction: column;
height: 100%;
}
.modal-body h3 {
font-size: xx-large;
font-weight: 100;
}
.modal-header {
@ -688,7 +1014,7 @@ body {
}
.modal-header h2 {
font-size: 1.2rem;
font-size: xx-large;
font-weight: 600;
}
@ -710,20 +1036,6 @@ body {
font-weight: 500;
}
.form-group input {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: rgba(15, 23, 42, 0.5);
color: var(--text-primary);
font-size: 0.9rem;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.settings-warning {
font-size: 0.75rem;
color: var(--text-muted);
@ -734,12 +1046,11 @@ body {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
margin-top: auto;
}
.btn {
padding: 10px 18px;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
@ -752,18 +1063,13 @@ body {
color: white;
}
.primary-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.secondary-btn {
background: rgba(255, 255, 255, 0.05);
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.secondary-btn:hover {
background: rgba(255, 255, 255, 0.1);
background: var(--text-primary);
color: var(--bg-main);
}

View file

@ -1,77 +0,0 @@
import axios from "axios";
const session = "session_01kxc62bk5fs8vh4v127k88a7j";
const wahaBaseUrl = "http://inspiran.beetal-castor.ts.net:3100";
const apiKey = process.env.WAHA_API_KEY;
const client = axios.create({
baseURL: wahaBaseUrl,
headers: {
"X-API-KEY": apiKey,
"Content-Type": "application/json"
}
});
export const wahaService = {
getConfigStatus() {
return {
wahaBaseUrl,
session,
hasApiKey: !!apiKey
};
},
async getChats() {
const response = await client.get(`/api/${session}/chats`, { timeout: 5000 });
if (!Array.isArray(response.data)) {
throw new Error("Invalid chats response format from WAHA API");
}
return response.data.map(chat => {
let chatId = chat.id;
if (chatId && typeof chatId === "object") {
chatId = chatId._serialized || chatId.user || JSON.stringify(chatId);
}
return {
id: chatId || chat.chatId || chat.name,
name: chat.name || "Unknown Contact",
unreadCount: chat.unreadCount || 0,
lastMessage: chat.lastMessage?.body || chat.lastMessageText || "Click to open chat",
timestamp: chat.lastMessage?.timestamp || new Date()
};
});
},
async getChatMessages(chatId) {
const response = await client.get(`/api/${session}/chats/${chatId}/messages?downloadMedia=false&merge=true&limit=10`);
return response.data;
},
async getMyInfo(chatId) {
const response = await client.get(`/api/${session}/me`);
return response.data;
},
async startTyping(chatId) {
await client.post("/api/startTyping", {
chatId,
session
}, { timeout: 3000 });
},
async stopTyping(chatId) {
await client.post("/api/stopTyping", {
chatId,
session
}, { timeout: 2000 });
},
async sendTextMessage(chatId, text) {
const response = await client.post("/api/sendText", {
chatId,
text
});
return response.data;
}
};