Compare commits
No commits in common. "5ba95800a3907bed587a20bab61a9edfc8f1d04d" and "584abdb00ea886a3b6549302615c2a44c3ca3fb3" have entirely different histories.
5ba95800a3
...
584abdb00e
33 changed files with 2003 additions and 2310 deletions
31
.gitignore
vendored
31
.gitignore
vendored
|
|
@ -1,29 +1,2 @@
|
|||
# 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
|
||||
.env
|
||||
node_modules
|
||||
48
README.md
48
README.md
|
|
@ -1,48 +0,0 @@
|
|||
<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
BIN
fonts/selawk.ttf
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
icon-192.png
BIN
icon-192.png
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 KiB |
BIN
icon-512.png
BIN
icon-512.png
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
173
index.html
173
index.html
|
|
@ -1,173 +0,0 @@
|
|||
<!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>
|
||||
21
index.js
Normal file
21
index.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
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
451
js/app.js
|
|
@ -1,451 +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, 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;
|
||||
}
|
||||
232
js/db.js
232
js/db.js
|
|
@ -1,232 +0,0 @@
|
|||
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
150
js/storage.js
|
|
@ -1,150 +0,0 @@
|
|||
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
396
js/ui.js
|
|
@ -1,396 +0,0 @@
|
|||
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();
|
||||
}
|
||||
};
|
||||
186
js/waha.js
186
js/waha.js
|
|
@ -1,186 +0,0 @@
|
|||
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;
|
||||
}
|
||||
};
|
||||
116
loading.css
116
loading.css
|
|
@ -1,116 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
{
|
||||
"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
BIN
message-long.ogg
Binary file not shown.
BIN
message.ogg
BIN
message.ogg
Binary file not shown.
File diff suppressed because one or more lines are too long
1036
package-lock.json
generated
Normal file
1036
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
19
package.json
Normal file
19
package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
263
public/app.js
Normal file
263
public/app.js
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -4,20 +4,14 @@ 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, bgImg, bgOpacity) {
|
||||
save(url, session, apiKey) {
|
||||
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);
|
||||
}
|
||||
};
|
||||
147
public/index.html
Normal file
147
public/index.html
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
<!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>
|
||||
|
|
@ -1,142 +1,48 @@
|
|||
@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: black;
|
||||
--bg-secondary: #242424;
|
||||
--sidebar-bg: black;
|
||||
--chat-bg: black;
|
||||
--bg-main: #090d16;
|
||||
--sidebar-bg: rgba(17, 24, 39, 0.75);
|
||||
--chat-bg: rgba(15, 23, 42, 0.6);
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--border-hover: rgba(255, 255, 255, 0.15);
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: rgb(134, 134, 134);
|
||||
--accent: white;
|
||||
--accent-gradient: black;
|
||||
--text-muted: #64748b;
|
||||
--accent: #6366f1;
|
||||
--accent-gradient: linear-gradient(135deg, #6366f1 0%, #a855f7 100%);
|
||||
--accent-hover: #4f46e5;
|
||||
--online-color: rgba(255, 255, 255, 0.6);
|
||||
--bubble-incoming: #01abaa;
|
||||
--bubble-outgoing: #016f6e;
|
||||
--input-bg: black;
|
||||
--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);
|
||||
--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: 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;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
||||
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%);
|
||||
}
|
||||
|
||||
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;
|
||||
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%);
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-weight: bold;
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
/* Sidebar Styling */
|
||||
|
|
@ -151,14 +57,11 @@ input:focus {
|
|||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 20px 0px 20px;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: self-start;
|
||||
}
|
||||
|
||||
#pandora-username {
|
||||
font-size: xx-large;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
|
|
@ -170,6 +73,7 @@ input:focus {
|
|||
.avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -182,10 +86,6 @@ input:focus {
|
|||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
}
|
||||
|
|
@ -221,9 +121,10 @@ input:focus {
|
|||
.icon-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border: none;
|
||||
background: var(--bg-main);
|
||||
color: var(--text-primary);
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -235,16 +136,13 @@ input:focus {
|
|||
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;
|
||||
|
|
@ -267,18 +165,19 @@ input:focus {
|
|||
|
||||
#chat-search {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
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);
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.25s ease;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
#chat-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
background: white;
|
||||
color: var(--bg-main);
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
|
|
@ -324,24 +223,19 @@ input:focus {
|
|||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.selected {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transform: scale(0.98) skewX(10deg);
|
||||
filter: blur(.1px);
|
||||
opacity: .6;
|
||||
.chat-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
|
||||
.selectable:active {
|
||||
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);
|
||||
}
|
||||
|
||||
.chat-item-info {
|
||||
|
|
@ -380,17 +274,16 @@ input:focus {
|
|||
|
||||
.chat-item-msg {
|
||||
font-size: 0.82rem;
|
||||
color: var(--accent);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 300;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chat-item .unread-badge {
|
||||
background: var(--accent);
|
||||
color: var(--bg-main);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
|
|
@ -426,17 +319,13 @@ input:focus {
|
|||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.sidebar-footer {
|
||||
padding: 14px 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: rgba(10, 15, 26, 0.4);
|
||||
}
|
||||
|
||||
/* Sidebar Footer */
|
||||
.api-status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -450,14 +339,14 @@ input:focus {
|
|||
height: 8px;
|
||||
background-color: var(--online-color);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
animation: pulse 1.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7);
|
||||
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1);
|
||||
|
|
@ -496,15 +385,15 @@ input:focus {
|
|||
.empty-state-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--bg-secondary);
|
||||
/* border: 1px solid var(--border-color); */
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
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 {
|
||||
|
|
@ -525,48 +414,15 @@ input:focus {
|
|||
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: .6em 1em;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
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
|
||||
);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(15, 23, 42, 0.3);
|
||||
}
|
||||
|
||||
.active-contact-info {
|
||||
|
|
@ -579,12 +435,12 @@ input:focus {
|
|||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.active-contact-info h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.contact-status {
|
||||
|
|
@ -605,35 +461,20 @@ input:focus {
|
|||
.messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
padding-bottom: .6rem;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
user-select: text;
|
||||
gap: 16px;
|
||||
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%);
|
||||
}
|
||||
|
||||
.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);
|
||||
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%);
|
||||
}
|
||||
|
||||
.message-group {
|
||||
margin-top: .8em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 65%;
|
||||
transition: .1s;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.message-group.incoming {
|
||||
|
|
@ -644,28 +485,17 @@ input:focus {
|
|||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.message-group.same-sender {
|
||||
margin-top: .4em;
|
||||
}
|
||||
|
||||
.message-sender {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.message-indicator {
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 0px 10px;
|
||||
color: var(--text-muted);
|
||||
margin-left: 12px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 18px;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
|
|
@ -676,33 +506,14 @@ input:focus {
|
|||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
|
|
@ -711,56 +522,22 @@ input:focus {
|
|||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
font-size: 0.7rem;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.message-image-attachement {
|
||||
max-width: 100%;
|
||||
max-height: 10em;
|
||||
.message-group.incoming .message-meta {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Chat Input Panel */
|
||||
.chat-input-panel {
|
||||
padding: .8rem;
|
||||
padding: 20px 24px;
|
||||
background: var(--input-bg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
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 {
|
||||
|
|
@ -773,30 +550,30 @@ input:focus {
|
|||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#message-input {
|
||||
flex: 1;
|
||||
padding: 12px 18px;
|
||||
background-color: var(--text-primary);
|
||||
border: medium solid var(--border-color);
|
||||
color: var(--bg-main);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
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;
|
||||
|
|
@ -808,6 +585,11 @@ input:focus {
|
|||
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);
|
||||
}
|
||||
|
|
@ -829,7 +611,7 @@ input:focus {
|
|||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 0px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
|
|
@ -841,169 +623,61 @@ input:focus {
|
|||
display: none !important;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
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 */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
flex: 0 0 100%;
|
||||
position: relative;
|
||||
left: auto;
|
||||
top: auto;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar.hidden {
|
||||
transform: translateX(-100%);
|
||||
display: flex !important; /* overrides standard .hidden */
|
||||
}
|
||||
|
||||
.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: 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;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
width: 100%;
|
||||
background: var(--bg-main);
|
||||
background: rgba(30, 41, 59, 0.85);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 480px;
|
||||
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 {
|
||||
|
|
@ -1014,7 +688,7 @@ input:focus {
|
|||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: xx-large;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
|
@ -1036,6 +710,20 @@ input:focus {
|
|||
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);
|
||||
|
|
@ -1046,11 +734,12 @@ input:focus {
|
|||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: auto;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 18px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
|
|
@ -1063,13 +752,18 @@ input:focus {
|
|||
color: white;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: var(--bg-secondary);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-main);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
198
public/ui.js
Normal file
198
public/ui.js
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
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();
|
||||
}
|
||||
};
|
||||
|
|
@ -79,45 +79,3 @@ 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);
|
||||
};
|
||||
}
|
||||
92
public/waha.js
Normal file
92
public/waha.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
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
|
||||
})
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { config } from "./config.js";
|
||||
import { isOnline, updateOnlineStatus } from "./storage.js";
|
||||
|
||||
let socket = null;
|
||||
let reconnectTimer = null;
|
||||
|
|
@ -7,7 +6,6 @@ let currentOnMessageCallback = null;
|
|||
|
||||
export const websocket = {
|
||||
connect(onMessageCallback) {
|
||||
if (!isOnline) return;
|
||||
currentOnMessageCallback = onMessageCallback;
|
||||
|
||||
this.disconnect(false);
|
||||
|
|
@ -23,7 +21,7 @@ export const websocket = {
|
|||
|
||||
const apiKey = config.apiKey;
|
||||
const session = config.session;
|
||||
const events = ['session.status', 'message.any'];
|
||||
const events = ['session.status', 'message', 'message.any'];
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
if (apiKey) {
|
||||
|
|
@ -66,7 +64,6 @@ export const websocket = {
|
|||
socket = null;
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
updateOnlineStatus();
|
||||
this.connect(currentOnMessageCallback);
|
||||
}, 5000);
|
||||
};
|
||||
77
wahaService.js
Normal file
77
wahaService.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
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;
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue