Add account setup workflow; move activeChatState to states.ts to avoid importing app.ts on ui.ts; add ScrollableView to ui.ts to contain and modularize scrollable views; make app layout use ScrollableView add option to delete data on settings page.
Some checks failed
Build Vite / build (24.x) (push) Has been cancelled

This commit is contained in:
天クマ 2026-08-16 17:33:59 -03:00
commit d4adfde73a
13 changed files with 652 additions and 301 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
dist/ dist/
node_modules/ node_modules/
.idea

View file

@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "tsc && vite build && vite preview"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",

175
web/app.html Normal file
View file

@ -0,0 +1,175 @@
<!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">
<div class="main-holder">
<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="_scrollableView" id="main-view">
<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">
</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">
<button id="markread-btn" class="icon-btn send-btn mif-done_all mif-2x" title="Mark read"></button>
<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 class="big-title" 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>
<div class="sidebar-footer">
<div class="api-status-badge">
<span class="pulse-dot"></span>
<span id="backend-status-text">Backend connected</span>
</div>
</div>
<footer class="modal-footer">
<button class="btn secondary-btn" id="purge-database">Delete data</button>
<button class="btn primary-btn" id="save-settings">Save</button>
</footer>
</div>
</section>
</div>
</div>
<!-- Client-side script loaded as ES Module -->
</div>
<script type="module" src="./src/app.ts"></script>
</body>
</html>

View file

@ -5,43 +5,64 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content" /> <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" /> <meta name="HandheldFriendly" content="true" />
<title>Pandora</title> <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="style.css">
<link rel="stylesheet" href="loading.css"> <link rel="stylesheet" href="loading.css">
<link rel="stylesheet" href="metroicons.css"> <link rel="stylesheet" href="metroicons.css">
<link rel="manifest" href="./manifest.json"> <link rel="manifest" href="./manifest.json">
</head> </head>
<body> <body>
<div class="app-container no-active-chat"> <div class="app-container _scrollableView">
<aside id="desktop-aside"> <section class="extra-page" id="welcome-page" style="display: flex;">
<button id="profile-sidebar-btn" class="icon-btn mif-person mif-2x" data-page="profile-page"></button> <h2 class="big-title" style="margin: 0 auto">Pandora</h2>
<button id="chats-sidebar-btn" class="icon-btn mif-qa mif-2x" data-page="chat-page"></button> <div class="modal-content">
<button id="settings-sidebar-btn" class="icon-btn mif-cog mif-2x" data-page="settings-page"></button> <div style="font-size: 20rem; margin: 0 0 auto auto; transform: translateX(40%); opacity: .4;" class="mif-qa mif"></div>
</aside> <div class="modal-body">
<p>
<!-- Sidebar --> Welcome to Pandora, we need to get some things set up before you can start messaging. It shouldn't take long.
<aside class="sidebar"> </p>
<header class="sidebar-header"> <p>This setup will guide you into linking your WAHA server to Pandora.</p>
<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>
<footer class="modal-footer">
<button class="btn primary-btn next-page-btn">Continue</button>
</footer>
</div> </div>
</section>
<div class="chat-list-container"> <section class="extra-page" id="server-page">
<div class="loading-chats" id="chats-loader"> <div class="modal-content">
<div class='loading-animation-wrapper'> <div class="modal-header"></div>
<div class="modal-body">
<h3>where's your 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>
<h3>credentials</h3>
<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</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>
<hr style="margin-top: auto">
<h3>I don't have a server</h3>
<p>You can setup a WAHA server on a system running Docker. <a href="https://waha.devlike.pro/docs/how-to/install/">See documentation.</a></p>
</div>
<footer class="modal-footer">
<button class="btn primary-btn next-page-btn">Connect</button>
</footer>
</div>
</section>
<section class="extra-page" id="connecting-page">
<h2 class="big-title" style="margin: 0 auto">Connecting</h2>
<div class="modal-content">
<div style="font-size: 20rem; margin: 0 0 auto auto; transform: translateX(40%); opacity: .4;" class="mif-broadcast mif"></div>
<div class="modal-body">
<p style="text-align: center;">Please wait while we test your connection to this WAHA server</p>
<p class="dim" id="connecting-status" style="text-align: center;">Asking for server version...</p>
<div class='loading-animation-wrapper' id="connecting-animation">
<div class="animation"> <div class="animation">
<div class="dot"></div> <div class="dot"></div>
<div class="dot"></div> <div class="dot"></div>
@ -51,124 +72,13 @@
</div> </div>
</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">
<button id="markread-btn" class="icon-btn send-btn mif-done_all mif-2x" title="Mark read"></button>
<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"> <footer class="modal-footer">
<button class="btn primary-btn" id="save-settings">Save</button> <button id="connecting-next-btn" class="btn primary-btn next-page-btn" style="display: none;">Finish</button>
</footer> </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> </div>
</section> </section>
</div> </div>
<!-- Client-side script loaded as ES Module --> <script type="module" src="./src/setup.ts"></script>
<script type="module" src="./src/app.ts"></script>
</body> </body>
</html> </html>

View file

@ -1,28 +1,34 @@
import { config } from "./config"; import { config } from "./config";
import { waha } from "./waha"; import { waha } from "./waha";
import { ui, elements } from "./ui"; import { ui, elements, views } from "./ui";
import { websocket } from "./websocket"; import { websocket } from "./websocket";
import { compensateMessageOrdering, debounce, formatTime, normalizeId } from "./utils"; import { compensateMessageOrdering, debounce, formatTime, normalizeId } from "./utils";
import { fetchChats, getAppUser, getChatMessages, getChatPicture, getChats, getUser, getUserAbout, markRead, sendStatus, updateOnlineStatus } from "./storage"; import { fetchChats, getAppUser, getChatMessages, getChatPicture, getChats, getUser, getUserAbout, markRead, sendStatus, updateOnlineStatus } from "./storage";
import { upsertMessages } from "./db"; import { deleteDatabase, upsertMessages } from "./db";
import { showNotification } from "./notification"; import { showNotification } from "./notification";
import type { Chat, Message, WebSocketEvent } from "./types"; import type { Chat, Message, WebSocketEvent } from "./types";
import { activeChatState, setActiveChatState } from "./states";
let activeChatState: Chat | null = null;
const messageTone = new Audio("./message.ogg"); const messageTone = new Audio("./message.ogg");
const longPressEvent = new CustomEvent("longpress"); const longPressEvent = new CustomEvent("longpress");
export let isLoadingChat = false; export let isLoadingChat = false;
export let notificationAuthorization: NotificationPermission = "default"; export let notificationAuthorization: NotificationPermission = "default";
const mainViewEl = document.getElementById("main-view");
if (!mainViewEl) throw console.error();
const mainView = views.get(mainViewEl);
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
updateSidebarPosition();
askForNotificationPermission(); askForNotificationPermission();
elements.inputApiKey.value = config.apiKey; if (elements.inputApiKey) elements.inputApiKey.value = config.apiKey;
elements.inputWahaUrl.value = config.wahaUrl; if (elements.inputWahaUrl) elements.inputWahaUrl.value = config.wahaUrl;
elements.inputSession.value = config.session; if (elements.inputSession) elements.inputSession.value = config.session;
elements.inputBackgroundImage.value = config.bgImg; if (elements.inputBackgroundImage) elements.inputBackgroundImage.value = config.bgImg;
elements.inputBackgroundOpacity.value = config.bgOpacity; if (elements.inputBackgroundOpacity) elements.inputBackgroundOpacity.value = config.bgOpacity;
elements.activeChatContainer.style.setProperty('--background-image', `URL("${config.bgImg}")`); if (elements.activeChatContainer) {
elements.activeChatContainer.style.setProperty('--background-opacity', `${config.bgOpacity}`); elements.activeChatContainer.style.setProperty('--background-image', `URL("${config.bgImg}")`);
elements.activeChatContainer.style.setProperty('--background-opacity', `${config.bgOpacity}`);
}
await updateOnlineStatus(); await updateOnlineStatus();
setupEventListeners(); setupEventListeners();
try { try {
@ -62,6 +68,15 @@ async function setupElementsData() {
} }
} }
function purgeDatabase(ask: boolean = true) {
if (ask) {
if (!(confirm("Are you sure you want to delete all cached messages?") && confirm("This cannot be undone. Proceed?"))) return;
}
localStorage.clear();
deleteDatabase();
location.reload();
}
function loadChats() { function loadChats() {
elements.chatsLoader.classList.remove('hidden'); elements.chatsLoader.classList.remove('hidden');
try { try {
@ -115,6 +130,8 @@ function scrollToList(smooth = true) {
} }
function setupEventListeners() { function setupEventListeners() {
window.addEventListener('resize', updateSidebarPosition);
if (!window.location.hash) { if (!window.location.hash) {
window.location.hash = ''; window.location.hash = '';
} }
@ -150,6 +167,14 @@ function setupEventListeners() {
}, 100); }, 100);
}); });
elements.appContainer.addEventListener('resize', () => {
if (window.innerWidth > 768) {
elements.desktopAside.after(elements.sidebar);
} else {
mainViewEl?.insertBefore(elements.sidebar, mainViewEl.firstChild);
}
})
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.code == "Escape") { if (e.code == "Escape") {
e.preventDefault(); e.preventDefault();
@ -203,12 +228,17 @@ function setupEventListeners() {
elements.desktopSidebarButtons.forEach(sidebarBtn => { elements.desktopSidebarButtons.forEach(sidebarBtn => {
sidebarBtn.addEventListener('click', () => { sidebarBtn.addEventListener('click', () => {
const page = sidebarBtn.dataset.page; const page = sidebarBtn.dataset.page;
if (page) ui.showExtraPage(page); if (!page) return;
const pageEl = document.getElementById(page);
if (!pageEl) return;
if (page) mainView?.scrollTo(pageEl);
}) })
}) })
elements.saveSettingsBtn.addEventListener('click', saveSettings); elements.saveSettingsBtn.addEventListener('click', saveSettings);
elements.purgeDatabaseButton.addEventListener('click', () => purgeDatabase(true));
elements.inputUserStatus.addEventListener('input', debounce(async function() { elements.inputUserStatus.addEventListener('input', debounce(async function() {
const result = await sendStatus(elements.inputUserStatus.value); const result = await sendStatus(elements.inputUserStatus.value);
if (result?.success) { if (result?.success) {
@ -244,6 +274,20 @@ function setupEventListeners() {
}) })
} }
function updateSidebarPosition() {
if (!mainViewEl || !elements.sidebar || !elements.desktopAside) return;
if (window.innerWidth > 768) {
if (elements.sidebar.parentElement !== elements.appContainer) {
elements.desktopAside.after(elements.sidebar);
}
} else {
if (elements.sidebar.parentElement !== mainViewEl) {
mainViewEl.insertBefore(elements.sidebar, mainViewEl.firstChild);
}
}
}
function initWebSocket() { function initWebSocket() {
websocket.connect((data: WebSocketEvent) => { websocket.connect((data: WebSocketEvent) => {
const ev = data.event; const ev = data.event;
@ -292,7 +336,7 @@ async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) {
if (isLoadingChat) return; if (isLoadingChat) return;
isLoadingChat = true; isLoadingChat = true;
activeChatState = chat; setActiveChatState(chat);
chat.unreadCount = 0; chat.unreadCount = 0;
@ -336,7 +380,7 @@ async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) {
} }
async function closeActiveChat(isPopState = false) { async function closeActiveChat(isPopState = false) {
activeChatState = null; setActiveChatState(null);
if (window.innerWidth <= 768) { if (window.innerWidth <= 768) {
scrollToList(); scrollToList();

View file

@ -8,8 +8,8 @@ export interface Config {
} }
export const config: Config = { export const config: Config = {
wahaUrl: localStorage.getItem('waha_url') || 'http://inspiran.beetal-castor.ts.net:3100', wahaUrl: localStorage.getItem('waha_url') || '',
session: localStorage.getItem('waha_session') || 'session_01kxc62bk5fs8vh4v127k88a7j', session: localStorage.getItem('waha_session') || '',
apiKey: localStorage.getItem('waha_api_key') || '', apiKey: localStorage.getItem('waha_api_key') || '',
bgImg: localStorage.getItem('background_image') || '', bgImg: localStorage.getItem('background_image') || '',
bgOpacity: localStorage.getItem('background_opacity') || '0.4', bgOpacity: localStorage.getItem('background_opacity') || '0.4',

View file

@ -243,3 +243,7 @@ export async function loadMedia(reqId: string): Promise<StoredMedia | undefined>
req.onerror = () => reject(req.error); req.onerror = () => reject(req.error);
}); });
} }
export function deleteDatabase() {
indexedDB.deleteDatabase(DB_NAME);
}

57
web/src/setup.ts Normal file
View file

@ -0,0 +1,57 @@
import { config } from "./config";
import { elements, ScrollableView, ui, views } from "./ui";
import { waha } from "./waha";
const page = views.get(elements.appContainer);
page?.scrollToIndex(0);
const statusConnection = document.getElementById("connecting-status");
const connectingPage = document.getElementById("connecting-page");
const loading = document.getElementById("connecting-animation");
const connectingNextBtn = document.getElementById("connecting-next-btn");
const settingsApiKey = document.getElementById("settings-api-key") as HTMLInputElement;
const settingsSession = document.getElementById("settings-session") as HTMLInputElement;
const settingsWahaURL = document.getElementById("settings-waha-url") as HTMLInputElement;
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
window.addEventListener('load', () => {
window.scrollTo(0, 0);
if (localStorage.getItem('setupComplete') == 'true') {
window.location.href = 'app.html';
}
});
if (connectingPage) {
connectingPage.addEventListener('intoView', () => {
testConnection();
})
}
connectingNextBtn?.addEventListener('click', () => {
localStorage.setItem('setupComplete', 'true');
window.location.href = "app.html";
})
async function testConnection() {
console.log(settingsWahaURL?.value)
config.save(settingsWahaURL?.value, settingsSession?.value, settingsApiKey?.value, "", "");
if (!statusConnection || !connectingNextBtn || !loading) return;
statusConnection.textContent = "Asking for server version...";
try {
const resp = await waha.getMyInfo();
if (resp.pushName != null) {
statusConnection.textContent = `Logged in as ${resp.pushName}`
connectingNextBtn.style.display = 'flex';
loading.style.display = 'none';
} else {
page?.previows();
}
} catch (e) {
console.log(e);
page?.previows();
}
}

7
web/src/states.ts Normal file
View file

@ -0,0 +1,7 @@
import { Chat } from "./types";
export let activeChatState: Chat | null = null;
export function setActiveChatState(value: Chat | null) {
activeChatState = value;
}

View file

@ -1,7 +1,7 @@
import { formatTime, normalizeId } from "./utils"; import { formatTime, normalizeId } from "./utils";
import { getChatPicture, getMessage, getMedia, getMoreChatMessages } from "./storage"; import { getChatPicture, getMessage, getMedia, getMoreChatMessages } from "./storage";
import { getCurrentChat } from "./app";
import type { Chat, Message } from "./types"; import type { Chat, Message } from "./types";
import { activeChatState } from "./states";
export const elements = { export const elements = {
chatList: document.getElementById('chat-list') as HTMLUListElement, chatList: document.getElementById('chat-list') as HTMLUListElement,
@ -22,6 +22,7 @@ export const elements = {
settingsModal: document.getElementById('settings-page') as HTMLElement, settingsModal: document.getElementById('settings-page') as HTMLElement,
settingsIconBtn: document.getElementById('settings-sidebar-btn') as HTMLButtonElement, settingsIconBtn: document.getElementById('settings-sidebar-btn') as HTMLButtonElement,
saveSettingsBtn: document.getElementById('save-settings') as HTMLButtonElement, saveSettingsBtn: document.getElementById('save-settings') as HTMLButtonElement,
purgeDatabaseButton: document.getElementById('purge-database') as HTMLButtonElement,
inputWahaUrl: document.getElementById('settings-waha-url') as HTMLInputElement, inputWahaUrl: document.getElementById('settings-waha-url') as HTMLInputElement,
inputSession: document.getElementById('settings-session') as HTMLInputElement, inputSession: document.getElementById('settings-session') as HTMLInputElement,
inputApiKey: document.getElementById('settings-api-key') as HTMLInputElement, inputApiKey: document.getElementById('settings-api-key') as HTMLInputElement,
@ -36,25 +37,20 @@ export const elements = {
markreadBtn: document.getElementById('markread-btn') as HTMLButtonElement, markreadBtn: document.getElementById('markread-btn') as HTMLButtonElement,
extraPages: document.querySelectorAll('.extra-page') as NodeListOf<HTMLElement>, extraPages: document.querySelectorAll('.extra-page') as NodeListOf<HTMLElement>,
desktopSidebarButtons: document.querySelectorAll("#desktop-aside button") as NodeListOf<HTMLButtonElement>, desktopSidebarButtons: document.querySelectorAll("#desktop-aside button") as NodeListOf<HTMLButtonElement>,
desktopAside: document.getElementById('desktop-aside') as HTMLElement,
contentUserName: document.querySelectorAll('[data-content="app-user"]') as NodeListOf<HTMLElement>, contentUserName: document.querySelectorAll('[data-content="app-user"]') as NodeListOf<HTMLElement>,
contentUserNumber: document.querySelectorAll('[data-content="app-user-number"]') as NodeListOf<HTMLElement>, contentUserNumber: document.querySelectorAll('[data-content="app-user-number"]') as NodeListOf<HTMLElement>,
resourceUserPic: document.querySelectorAll('[data-resource="app-user-image"]') as NodeListOf<HTMLImageElement>, resourceUserPic: document.querySelectorAll('[data-resource="app-user-image"]') as NodeListOf<HTMLImageElement>,
valueUserStatus: document.querySelectorAll('[data-value="app-user-status"]') as NodeListOf<HTMLInputElement>, valueUserStatus: document.querySelectorAll('[data-value="app-user-status"]') as NodeListOf<HTMLInputElement>,
inputUserStatus: document.getElementById('profile-page-status-input') as HTMLInputElement, inputUserStatus: document.getElementById('profile-page-status-input') as HTMLInputElement,
selectable: document.querySelectorAll('.selectable') as NodeListOf<HTMLElement>, selectable: document.querySelectorAll('.selectable') as NodeListOf<HTMLElement>,
nextPageButtons: document.querySelectorAll('.next-page-btn') as NodeListOf<HTMLElement>,
scrollableViews: document.querySelectorAll('._scrollableView') as NodeListOf<HTMLElement>
}; };
export const ui = { export const views = new Map<HTMLElement, ScrollableView>;
showExtraPage(pageId: string) {
elements.extraPages.forEach(page => {
if (page.id != pageId) {
page.style.display = "none";
} else {
page.style.display = "flex";
}
})
},
export const ui = {
/** /**
* Switch view state when a contact chat is opened or closed * Switch view state when a contact chat is opened or closed
*/ */
@ -72,7 +68,19 @@ export const ui = {
* Scroll message list automatically to bottom * Scroll message list automatically to bottom
*/ */
scrollToBottom() { scrollToBottom() {
elements.messagesContainer.scrollTop = elements.messagesContainer.scrollHeight; requestAnimationFrame(() => {
requestAnimationFrame(() => {
elements.messagesContainer.scrollTop = elements.messagesContainer.scrollHeight;
});
});
},
ensureScroll(container: HTMLElement, after: Function) {
const scrolled = container.scrollTop === (container.scrollHeight - container.clientHeight);
after();
if (scrolled) {
container.scrollTop = container.scrollHeight;
}
}, },
/** /**
@ -103,6 +111,7 @@ export const ui = {
} }
for (const chat of chats) { for (const chat of chats) {
if (chat.timestamp == null) return;
const li = document.createElement('li'); const li = document.createElement('li');
li.className = `chat-item selectable ${activeChat && activeChat.id === chat.id ? 'active' : ''}`; li.className = `chat-item selectable ${activeChat && activeChat.id === chat.id ? 'active' : ''}`;
li.dataset.id = chat.id; li.dataset.id = chat.id;
@ -125,8 +134,8 @@ export const ui = {
<span class="chat-item-time">${timeStr}</span> <span class="chat-item-time">${timeStr}</span>
</div> </div>
<div class="chat-item-preview"> <div class="chat-item-preview">
<span class="chat-item-msg" data-chatid="${chat.id}"> <span class="chat-item-msg${chat.lastMessage == null ? ' text-accent' : ''}" data-chatid="${chat.id}">
${chat.lastMessage || 'No messages yet'} ${chat.lastMessage || '...'}
</span> </span>
${hasUnread ? `<span class="unread-badge">${chat.unreadCount}</span>` : ''} ${hasUnread ? `<span class="unread-badge">${chat.unreadCount}</span>` : ''}
</div> </div>
@ -156,8 +165,6 @@ export const ui = {
messageItem.innerText = msg.body || msg.text || 'Media message'; messageItem.innerText = msg.body || msg.text || 'Media message';
time.innerText = msg.timestamp ? formatTime(msg.timestamp) : formatTime(Date.now()); time.innerText = msg.timestamp ? formatTime(msg.timestamp) : formatTime(Date.now());
const activeChatState = getCurrentChat();
if (!msg.fromMe && (!activeChatState || activeChatState.id !== (msg.fromMe ? msg.to : msg.from))) { if (!msg.fromMe && (!activeChatState || activeChatState.id !== (msg.fromMe ? msg.to : msg.from))) {
const unreadBadge = chatNode.querySelector('.unread-badge') as HTMLElement; const unreadBadge = chatNode.querySelector('.unread-badge') as HTMLElement;
if (unreadBadge) { if (unreadBadge) {
@ -351,9 +358,11 @@ export const ui = {
const img = document.createElement('img'); const img = document.createElement('img');
img.classList.add('message-image-attachement'); img.classList.add('message-image-attachement');
img.src = objectUrl; img.src = objectUrl;
bubble.after(img); this.ensureScroll(elements.messagesContainer, () => {
const content = bubble.querySelector('.message-content'); bubble.before(img);
if (content) content.remove(); })
// const content = bubble.querySelector('.message-content');
// if (content) content.remove(); // TODO make images attachment with text look cooler
} else { } else {
(e.target as HTMLAnchorElement).textContent = media.filename || `Download ${mediaMsg.media.filename}`; (e.target as HTMLAnchorElement).textContent = media.filename || `Download ${mediaMsg.media.filename}`;
} }
@ -362,7 +371,9 @@ export const ui = {
a.addEventListener('click', clickListener); a.addEventListener('click', clickListener);
} }
contentEl.appendChild(a); this.ensureScroll(elements.messagesContainer, () => {
contentEl.appendChild(a);
});
if (!isLocal && msg._data?.mimetype?.startsWith('image/')) { if (!isLocal && msg._data?.mimetype?.startsWith('image/')) {
a.click(); a.click();
@ -435,5 +446,108 @@ export const ui = {
removeChatMessage(msgId: string) { removeChatMessage(msgId: string) {
const message = document.getElementById(msgId); const message = document.getElementById(msgId);
if (message) message.remove(); if (message) message.remove();
} },
}; };
export class ScrollableView {
private observer: IntersectionObserver | null = null;
private activePage: HTMLElement | null = null;
container: HTMLElement;
isScrollingProgrammatically = false;
scrollTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(container: HTMLElement) {
this.container = container;
this.setupIntersectionObserver();
window.addEventListener('scroll', (e) => {
if (this.isScrollingProgrammatically) e.preventDefault();
});
container.querySelectorAll('.next-page-btn').forEach(nextBtn => {
nextBtn.addEventListener('click', () => {
this.next();
})
})
}
private setupIntersectionObserver() {
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.target !== this.activePage) {
this.activePage = entry.target as HTMLElement;
entry.target.dispatchEvent(new CustomEvent('intoView', {
bubbles: true,
detail: {
page: entry.target,
index: this.getCurrentIndex()
}
}));
}
});
}, {
root: this.container,
threshold: 0.6
});
this.observePages();
}
observePages() {
if (!this.observer) return;
Array.from(this.container.children).forEach(child => {
this.observer?.observe(child);
});
}
get pages(): HTMLElement[] {
return Array.from(this.container.children) as HTMLElement[];
}
getCurrentIndex(): number {
const width = this.container.clientWidth;
if (width === 0) return 0;
return Math.round(this.container.scrollLeft / width);
}
getCurrentScreen(): HTMLElement | null {
const pages = this.pages;
return pages[this.getCurrentIndex()] || null;
}
scrollToIndex(index: number, smooth = true) {
const pages = this.pages;
if (index >= 0 && index < pages.length) {
this.scrollTo(pages[index], smooth);
}
}
next(smooth = true) {
this.scrollToIndex(this.getCurrentIndex() + 1, smooth);
}
previows(smooth = true) {
this.scrollToIndex(this.getCurrentIndex() - 1, smooth);
}
scrollTo(element: HTMLElement, smooth = true) {
this.isScrollingProgrammatically = true;
element.scrollIntoView({
behavior: smooth ? 'smooth' : 'auto',
inline: 'start',
block: 'nearest'
});
setTimeout(() => { this.isScrollingProgrammatically = false; }, smooth ? 400 : 50);
}
getCurrentExtraPage(): number {
const pages = Array.from(elements.extraPages);
const activeIndex = pages.findIndex(page => page.classList.contains("shown"));
return activeIndex !== -1 ? activeIndex : 0;
}
}
elements.scrollableViews.forEach(view => {
views.set(view, new ScrollableView(view));
})

View file

@ -79,11 +79,11 @@ export const waha = {
chatId = chatId._serialized || chatId.user || JSON.stringify(chatId); chatId = chatId._serialized || chatId.user || JSON.stringify(chatId);
} }
return { return {
id: chatId || chat.chatId || chat.name, id: chatId || chat.chatId || null,
name: chat.name || "Unknown Contact", name: chat.name || null,
unreadCount: chat.unreadCount || 0, unreadCount: chat.unreadCount || 0,
lastMessage: chat.lastMessage?.body || chat.lastMessageText || "Click to open chat", lastMessage: chat.lastMessage?.body || chat.lastMessageText || null,
timestamp: chat.lastMessage?.timestamp || new Date() timestamp: chat.lastMessage?.timestamp || null
}; };
}); });
}, },

View file

@ -93,9 +93,6 @@ html, body {
body { body {
background-color: var(--bg-main); background-color: var(--bg-main);
color: var(--text-primary); color: var(--text-primary);
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 { a {
@ -129,16 +126,44 @@ input:focus {
.app-container { .app-container {
display: flex; display: flex;
flex-direction: row;
width: 100%;
height: 100dvh;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x mandatory;
scrollbar-width: none;
-ms-overflow-style: none;
}
.main-holder {
display: flex;
}
._scrollableView, ._scrollableContainer {
display: flex;
flex-direction: row;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden; overflow-x: auto;
position: relative; overflow-y: hidden;
scroll-snap-type: x mandatory;
scrollbar-width: none;
-ms-overflow-style: none;
}
.app-container::-webkit-scrollbar, ._scrollableContainer::-webkit-scrollbar, ._scrollableView::-webkit-scrollbar {
display: none;
} }
.app-name { .app-name {
font-weight: bold; font-weight: bold;
} }
.dim {
opacity: .6;
}
/* Sidebar Styling */ /* Sidebar Styling */
.sidebar { .sidebar {
width: 380px; width: 380px;
@ -237,10 +262,6 @@ input:focus {
border-color: var(--border-hover); border-color: var(--border-hover);
} }
#chat-bottom-bar .icon-btn:hover::before {
color: var(--bg-main);
}
.icon-btn:active { .icon-btn:active {
transform: translateY(0); transform: translateY(0);
} }
@ -444,6 +465,7 @@ input:focus {
.api-status-badge { .api-status-badge {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
gap: 8px; gap: 8px;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-secondary); color: var(--text-secondary);
@ -480,6 +502,7 @@ input:focus {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
height: 100%;
} }
/* Empty State */ /* Empty State */
@ -847,8 +870,14 @@ input:focus {
/* Extra Pages */ /* Extra Pages */
.extra-page { .extra-page {
display: none; display: flex;
flex-direction: column;
width: 100%; width: 100%;
height: 100%;
scroll-snap-align: start;
scroll-snap-stop: always;
flex: 0 0 100%;
} }
.extra-page-content { .extra-page-content {
@ -875,7 +904,7 @@ input:focus {
flex: 1; flex: 1;
} }
#profile-page h2 { .big-title {
padding: 1.4rem; padding: 1.4rem;
font-size: 5rem; font-size: 5rem;
text-wrap-mode: nowrap; text-wrap-mode: nowrap;
@ -935,13 +964,12 @@ input:focus {
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
/* Hide scrollbars */ scrollbar-width: none;
scrollbar-width: none; /* Firefox */ -ms-overflow-style: none;
-ms-overflow-style: none; /* IE and Edge */
} }
.app-container::-webkit-scrollbar { .app-container::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */ display: none;
} }
.sidebar { .sidebar {
@ -969,7 +997,7 @@ input:focus {
} }
.extra-page { .extra-page {
display: block; display: flex;
width: 100%; width: 100%;
flex: 0 0 100%; flex: 0 0 100%;
position: relative; position: relative;
@ -1018,7 +1046,6 @@ input:focus {
.modal-content { .modal-content {
width: 100%; width: 100%;
background: var(--bg-main); background: var(--bg-main);
padding: 24px;
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
backdrop-filter: var(--glass-blur); backdrop-filter: var(--glass-blur);
display: flex; display: flex;
@ -1035,7 +1062,7 @@ input:focus {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px; padding: 1em 2em 0em 2em;
} }
.modal-header h2 { .modal-header h2 {
@ -1047,6 +1074,11 @@ input:focus {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
min-height: 0;
overflow-y: auto;
flex: 1;
overflow-x: hidden;
padding: 0 2rem 0 2rem;
} }
.form-group { .form-group {
@ -1071,7 +1103,7 @@ input:focus {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 12px; gap: 12px;
margin-top: auto; padding: 1rem 2rem;
} }
.btn { .btn {
@ -1084,8 +1116,15 @@ input:focus {
} }
.primary-btn { .primary-btn {
background: var(--accent-gradient); background: transparent;
color: white; color: var(--text-primary);
border: medium solid var(--bg-secondary);
}
.primary-btn:hover {
background: var(--text-primary);
color: var(--bg-main);
border-color: var(--text-primary);
} }
.secondary-btn { .secondary-btn {