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
Some checks failed
Build Vite / build (24.x) (push) Has been cancelled
This commit is contained in:
parent
a467c41432
commit
d4adfde73a
13 changed files with 652 additions and 301 deletions
175
web/app.html
Normal file
175
web/app.html
Normal 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>
|
||||
194
web/index.html
194
web/index.html
|
|
@ -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="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 class="app-container _scrollableView">
|
||||
<section class="extra-page" id="welcome-page" style="display: flex;">
|
||||
<h2 class="big-title" style="margin: 0 auto">Pandora</h2>
|
||||
<div class="modal-content">
|
||||
<div style="font-size: 20rem; margin: 0 0 auto auto; transform: translateX(40%); opacity: .4;" class="mif-qa mif"></div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
Welcome to Pandora, we need to get some things set up before you can start messaging. It shouldn't take long.
|
||||
</p>
|
||||
<p>This setup will guide you into linking your WAHA server to Pandora.</p>
|
||||
</div>
|
||||
<footer class="modal-footer">
|
||||
<button class="btn primary-btn next-page-btn">Continue</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="chat-list-container">
|
||||
<div class="loading-chats" id="chats-loader">
|
||||
<div class='loading-animation-wrapper'>
|
||||
</section>
|
||||
<section class="extra-page" id="server-page">
|
||||
<div class="modal-content">
|
||||
<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="dot"></div>
|
||||
<div class="dot"></div>
|
||||
|
|
@ -51,124 +72,13 @@
|
|||
</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">
|
||||
<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>
|
||||
<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="./src/app.ts"></script>
|
||||
<script type="module" src="./src/setup.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
164
web/src/app.ts
164
web/src/app.ts
|
|
@ -1,28 +1,34 @@
|
|||
import { config } from "./config";
|
||||
import { waha } from "./waha";
|
||||
import { ui, elements } from "./ui";
|
||||
import { ui, elements, views } from "./ui";
|
||||
import { websocket } from "./websocket";
|
||||
import { compensateMessageOrdering, debounce, formatTime, normalizeId } from "./utils";
|
||||
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 type { Chat, Message, WebSocketEvent } from "./types";
|
||||
import { activeChatState, setActiveChatState } from "./states";
|
||||
|
||||
let activeChatState: Chat | null = null;
|
||||
const messageTone = new Audio("./message.ogg");
|
||||
const longPressEvent = new CustomEvent("longpress");
|
||||
export let isLoadingChat = false;
|
||||
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 () => {
|
||||
updateSidebarPosition();
|
||||
askForNotificationPermission();
|
||||
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}`);
|
||||
if (elements.inputApiKey) elements.inputApiKey.value = config.apiKey;
|
||||
if (elements.inputWahaUrl) elements.inputWahaUrl.value = config.wahaUrl;
|
||||
if (elements.inputSession) elements.inputSession.value = config.session;
|
||||
if (elements.inputBackgroundImage) elements.inputBackgroundImage.value = config.bgImg;
|
||||
if (elements.inputBackgroundOpacity) elements.inputBackgroundOpacity.value = config.bgOpacity;
|
||||
if (elements.activeChatContainer) {
|
||||
elements.activeChatContainer.style.setProperty('--background-image', `URL("${config.bgImg}")`);
|
||||
elements.activeChatContainer.style.setProperty('--background-opacity', `${config.bgOpacity}`);
|
||||
}
|
||||
await updateOnlineStatus();
|
||||
setupEventListeners();
|
||||
try {
|
||||
|
|
@ -62,12 +68,21 @@ 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() {
|
||||
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-', '');
|
||||
|
|
@ -115,10 +130,12 @@ function scrollToList(smooth = true) {
|
|||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
window.addEventListener('resize', updateSidebarPosition);
|
||||
|
||||
if (!window.location.hash) {
|
||||
window.location.hash = '';
|
||||
}
|
||||
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
const hash = window.location.hash;
|
||||
if (hash && hash.startsWith('#chat-')) {
|
||||
|
|
@ -131,16 +148,16 @@ function setupEventListeners() {
|
|||
closeActiveChat(true);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
elements.appContainer.addEventListener('scroll', () => {
|
||||
if (window.innerWidth > 768) return;
|
||||
if (isScrollingProgrammatically) return;
|
||||
|
||||
|
||||
if (scrollTimeout) 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) {
|
||||
|
|
@ -149,14 +166,22 @@ function setupEventListeners() {
|
|||
}
|
||||
}, 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) => {
|
||||
if (e.code == "Escape") {
|
||||
e.preventDefault();
|
||||
closeActiveChat(false);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
elements.chatSearch.addEventListener('input', (e: Event) => {
|
||||
const query = (e.target as HTMLInputElement).value.toLowerCase();
|
||||
const filtered = getChats().filter(chat =>
|
||||
|
|
@ -164,24 +189,24 @@ function setupEventListeners() {
|
|||
);
|
||||
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.markreadBtn.addEventListener('click', async () => {
|
||||
if (activeChatState) {
|
||||
const result = await markRead(activeChatState.id);
|
||||
|
|
@ -195,20 +220,25 @@ function setupEventListeners() {
|
|||
const firstFile = this.files?.[0];
|
||||
if (firstFile) sendFileMessage(firstFile);
|
||||
})
|
||||
|
||||
|
||||
elements.backToSidebarBtn.addEventListener('click', () => {
|
||||
closeActiveChat(false);
|
||||
});
|
||||
|
||||
|
||||
elements.desktopSidebarButtons.forEach(sidebarBtn => {
|
||||
sidebarBtn.addEventListener('click', () => {
|
||||
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.purgeDatabaseButton.addEventListener('click', () => purgeDatabase(true));
|
||||
|
||||
elements.inputUserStatus.addEventListener('input', debounce(async function() {
|
||||
const result = await sendStatus(elements.inputUserStatus.value);
|
||||
if (result?.success) {
|
||||
|
|
@ -218,32 +248,46 @@ function setupEventListeners() {
|
|||
}
|
||||
console.log(result);
|
||||
}, 2000))
|
||||
|
||||
|
||||
elements.selectable.forEach(e => {
|
||||
let timerId: ReturnType<typeof setTimeout>, longPressed: boolean;
|
||||
|
||||
|
||||
e.addEventListener('mousedown', () => {
|
||||
longPressed = false;
|
||||
|
||||
|
||||
timerId = setTimeout(() => {
|
||||
longPressed = true;
|
||||
e.dispatchEvent(longPressEvent);
|
||||
}, 500); // 500ms for long press
|
||||
})
|
||||
|
||||
|
||||
e.addEventListener('click', (event) => {
|
||||
if (longPressed) {
|
||||
event.preventDefault();
|
||||
clearTimeout(timerId);
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
e.addEventListener('mouseleave', () => {
|
||||
clearTimeout(timerId);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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() {
|
||||
websocket.connect((data: WebSocketEvent) => {
|
||||
const ev = data.event;
|
||||
|
|
@ -257,23 +301,23 @@ function initWebSocket() {
|
|||
|
||||
async function handleIncomingMessage(msg: Message) {
|
||||
if (!msg) return;
|
||||
|
||||
|
||||
ui.updateChatInChatList(msg);
|
||||
|
||||
|
||||
const rawChatId = msg.chatId || (typeof msg.from === 'string' ? msg.from : (msg.from as any)?._serialized) || (msg.chat && msg.chat.id);
|
||||
const msgChatId = normalizeId(rawChatId);
|
||||
if (!msgChatId) {
|
||||
console.warn('[WS] Could not resolve chatId from payload:', msg);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!msg.fromMe) {
|
||||
messageTone.play();
|
||||
}
|
||||
if (notificationAuthorization === "granted") {
|
||||
new Notification("New message", { body: msg.body || msg.text });
|
||||
}
|
||||
|
||||
|
||||
if (activeChatState && activeChatState.id === msgChatId) {
|
||||
const msgId = normalizeId(msg.id as any) || (msg.id as string);
|
||||
const exists = document.getElementById(msgId);
|
||||
|
|
@ -290,16 +334,16 @@ async function handleIncomingMessage(msg: Message) {
|
|||
|
||||
async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) {
|
||||
if (isLoadingChat) return;
|
||||
|
||||
|
||||
isLoadingChat = true;
|
||||
activeChatState = chat;
|
||||
|
||||
setActiveChatState(chat);
|
||||
|
||||
chat.unreadCount = 0;
|
||||
|
||||
|
||||
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">
|
||||
|
|
@ -311,18 +355,18 @@ async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) {
|
|||
<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);
|
||||
|
|
@ -331,20 +375,20 @@ async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) {
|
|||
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;
|
||||
|
||||
setActiveChatState(null);
|
||||
|
||||
if (window.innerWidth <= 768) {
|
||||
scrollToList();
|
||||
} else {
|
||||
ui.toggleChatState(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if (!isPopState) {
|
||||
if (window.location.hash.startsWith('#chat-')) {
|
||||
history.back();
|
||||
|
|
@ -355,9 +399,9 @@ async function closeActiveChat(isPopState = false) {
|
|||
async function sendMessage() {
|
||||
const text = elements.messageInput.value.trim();
|
||||
if (!text || !activeChatState) return;
|
||||
|
||||
|
||||
elements.messageInput.value = '';
|
||||
|
||||
|
||||
const tempMsg = {
|
||||
id: 'temp-' + Date.now(),
|
||||
body: text,
|
||||
|
|
@ -366,10 +410,10 @@ async function sendMessage() {
|
|||
timestamp: new Date().toISOString(),
|
||||
status: 'sending'
|
||||
} as any;
|
||||
|
||||
|
||||
ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id);
|
||||
ui.scrollToBottom();
|
||||
|
||||
|
||||
try {
|
||||
try {
|
||||
await waha.startTyping(activeChatState.id);
|
||||
|
|
@ -378,13 +422,13 @@ async function sendMessage() {
|
|||
} 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);
|
||||
|
|
@ -392,9 +436,9 @@ async function sendMessage() {
|
|||
} catch (e: any) {
|
||||
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) {
|
||||
|
|
@ -403,7 +447,7 @@ async function sendMessage() {
|
|||
const meta = tempBubble.querySelector('.message-meta');
|
||||
if (meta) meta.innerHTML = `<span>${formatTime(new Date())}</span><span style="width:14px; height:14px;" class="mif-done">`;
|
||||
}
|
||||
|
||||
|
||||
activeChatState.lastMessage = text;
|
||||
activeChatState.timestamp = new Date().toISOString();
|
||||
} catch (error) {
|
||||
|
|
@ -436,10 +480,10 @@ async function sendFileMessage(file: File) {
|
|||
filename: file.name
|
||||
}
|
||||
} as any;
|
||||
|
||||
|
||||
ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id, true);
|
||||
ui.scrollToBottom();
|
||||
|
||||
|
||||
const result = await waha.sendFileMessage(activeChatState.id, file);
|
||||
ui.removeChatMessage(tempId);
|
||||
ui.appendSingleMessage(result, activeChatState.name, (await getAppUser()).id);
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ export interface Config {
|
|||
}
|
||||
|
||||
export const config: Config = {
|
||||
wahaUrl: localStorage.getItem('waha_url') || 'http://inspiran.beetal-castor.ts.net:3100',
|
||||
session: localStorage.getItem('waha_session') || 'session_01kxc62bk5fs8vh4v127k88a7j',
|
||||
wahaUrl: localStorage.getItem('waha_url') || '',
|
||||
session: localStorage.getItem('waha_session') || '',
|
||||
apiKey: localStorage.getItem('waha_api_key') || '',
|
||||
bgImg: localStorage.getItem('background_image') || '',
|
||||
bgOpacity: localStorage.getItem('background_opacity') || '0.4',
|
||||
|
|
|
|||
|
|
@ -243,3 +243,7 @@ export async function loadMedia(reqId: string): Promise<StoredMedia | undefined>
|
|||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteDatabase() {
|
||||
indexedDB.deleteDatabase(DB_NAME);
|
||||
}
|
||||
57
web/src/setup.ts
Normal file
57
web/src/setup.ts
Normal 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
7
web/src/states.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Chat } from "./types";
|
||||
|
||||
export let activeChatState: Chat | null = null;
|
||||
|
||||
export function setActiveChatState(value: Chat | null) {
|
||||
activeChatState = value;
|
||||
}
|
||||
|
|
@ -164,4 +164,4 @@ export async function markRead(chatId: string): Promise<Chat | undefined> {
|
|||
await upsertChats([chat]);
|
||||
}
|
||||
return chat;
|
||||
}
|
||||
}
|
||||
270
web/src/ui.ts
270
web/src/ui.ts
|
|
@ -1,7 +1,7 @@
|
|||
import { formatTime, normalizeId } from "./utils";
|
||||
import { getChatPicture, getMessage, getMedia, getMoreChatMessages } from "./storage";
|
||||
import { getCurrentChat } from "./app";
|
||||
import type { Chat, Message } from "./types";
|
||||
import { activeChatState } from "./states";
|
||||
|
||||
export const elements = {
|
||||
chatList: document.getElementById('chat-list') as HTMLUListElement,
|
||||
|
|
@ -22,6 +22,7 @@ export const elements = {
|
|||
settingsModal: document.getElementById('settings-page') as HTMLElement,
|
||||
settingsIconBtn: document.getElementById('settings-sidebar-btn') as HTMLButtonElement,
|
||||
saveSettingsBtn: document.getElementById('save-settings') as HTMLButtonElement,
|
||||
purgeDatabaseButton: document.getElementById('purge-database') as HTMLButtonElement,
|
||||
inputWahaUrl: document.getElementById('settings-waha-url') as HTMLInputElement,
|
||||
inputSession: document.getElementById('settings-session') as HTMLInputElement,
|
||||
inputApiKey: document.getElementById('settings-api-key') as HTMLInputElement,
|
||||
|
|
@ -36,25 +37,20 @@ export const elements = {
|
|||
markreadBtn: document.getElementById('markread-btn') as HTMLButtonElement,
|
||||
extraPages: document.querySelectorAll('.extra-page') as NodeListOf<HTMLElement>,
|
||||
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>,
|
||||
contentUserNumber: document.querySelectorAll('[data-content="app-user-number"]') as NodeListOf<HTMLElement>,
|
||||
resourceUserPic: document.querySelectorAll('[data-resource="app-user-image"]') as NodeListOf<HTMLImageElement>,
|
||||
valueUserStatus: document.querySelectorAll('[data-value="app-user-status"]') as NodeListOf<HTMLInputElement>,
|
||||
inputUserStatus: document.getElementById('profile-page-status-input') as HTMLInputElement,
|
||||
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 = {
|
||||
showExtraPage(pageId: string) {
|
||||
elements.extraPages.forEach(page => {
|
||||
if (page.id != pageId) {
|
||||
page.style.display = "none";
|
||||
} else {
|
||||
page.style.display = "flex";
|
||||
}
|
||||
})
|
||||
},
|
||||
export const views = new Map<HTMLElement, ScrollableView>;
|
||||
|
||||
export const ui = {
|
||||
/**
|
||||
* Switch view state when a contact chat is opened or closed
|
||||
*/
|
||||
|
|
@ -67,14 +63,26 @@ export const ui = {
|
|||
elements.activeChatContainer.classList.add('hidden');
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Scroll message list automatically to bottom
|
||||
*/
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update connection status badge in sidebar footer
|
||||
*/
|
||||
|
|
@ -88,7 +96,7 @@ export const ui = {
|
|||
elements.apiStatusIndicator.style.animation = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async renderChatList(chats: Chat[], activeChat: Chat | null, onChatSelect: (chat: Chat) => void) {
|
||||
elements.chatList.innerHTML = '';
|
||||
chats.sort((a, b) => {
|
||||
|
|
@ -96,21 +104,22 @@ export const ui = {
|
|||
const timeB = new Date(b.timestamp).getTime();
|
||||
return timeB - timeA;
|
||||
});
|
||||
|
||||
|
||||
if (chats.length === 0) {
|
||||
elements.chatList.innerHTML = `<li class="loading-chats">No chats found</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
for (const chat of chats) {
|
||||
if (chat.timestamp == null) return;
|
||||
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
|
||||
|
|
@ -125,17 +134,17 @@ export const ui = {
|
|||
<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 class="chat-item-msg${chat.lastMessage == null ? ' text-accent' : ''}" data-chatid="${chat.id}">
|
||||
${chat.lastMessage || '...'}
|
||||
</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);
|
||||
|
|
@ -146,18 +155,16 @@ export const ui = {
|
|||
})();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async updateChatInChatList(msg: Message) {
|
||||
const chatNode = document.querySelector(`.chat-item[data-id="${msg.fromMe ? msg.to : msg.from}"]`) as HTMLElement;
|
||||
|
||||
|
||||
if (chatNode) {
|
||||
const messageItem = chatNode.querySelector('.chat-item-msg') as HTMLElement;
|
||||
const time = chatNode.querySelector('.chat-item-time') as HTMLElement;
|
||||
messageItem.innerText = msg.body || msg.text || 'Media message';
|
||||
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))) {
|
||||
const unreadBadge = chatNode.querySelector('.unread-badge') as HTMLElement;
|
||||
if (unreadBadge) {
|
||||
|
|
@ -172,10 +179,10 @@ export const ui = {
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async updateChatInChatList2(chat: Chat) {
|
||||
const chatNode = document.querySelector(`.chat-item[data-id="${chat.id}"]`) as HTMLElement;
|
||||
|
||||
|
||||
if (chatNode) {
|
||||
const messageItem = chatNode.querySelector('.chat-item-msg') as HTMLElement;
|
||||
const time = chatNode.querySelector('.chat-item-time') as HTMLElement;
|
||||
|
|
@ -190,18 +197,18 @@ export const ui = {
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Render chat message log inside chat view container
|
||||
*/
|
||||
async renderMessages(messages: Message[], _activeChatName: string, userID: string, chatId: string) {
|
||||
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";
|
||||
|
|
@ -209,44 +216,44 @@ export const ui = {
|
|||
this.loadMoreMessages(chatId, userID);
|
||||
};
|
||||
elements.messagesContainer.appendChild(loadMore);
|
||||
|
||||
|
||||
for (const msg of messages) {
|
||||
this.appendSingleMessage(msg, userID, chatId);
|
||||
}
|
||||
|
||||
|
||||
this.scrollToBottom();
|
||||
},
|
||||
|
||||
|
||||
async loadMoreMessages(chatId: string, userId: string) {
|
||||
const oldest = document.querySelector('.message-group:first-of-type') as HTMLElement;
|
||||
if (!oldest) return;
|
||||
const oldestTimestamp = oldest.dataset.timestamp;
|
||||
const oldestId = oldest.id;
|
||||
|
||||
|
||||
const loadMoreButton = document.querySelector('.load-more-btn') as HTMLButtonElement;
|
||||
|
||||
|
||||
const msgs = await getMoreChatMessages(chatId, oldestTimestamp, oldestId);
|
||||
// JS version had msgs.shift(), probably to avoid duplication of the oldest message
|
||||
msgs.shift();
|
||||
|
||||
|
||||
msgs.forEach(async msg => {
|
||||
loadMoreButton.after(this.generateMessage(msg, userId, chatId));
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Append a single message (used for optimistic updates immediately upon sending)
|
||||
*/
|
||||
appendSingleMessage(msg: Message, userID: string, chatId: string, isLocal: boolean = false) {
|
||||
elements.messagesContainer.appendChild(this.generateMessage(msg, userID, chatId, isLocal))
|
||||
},
|
||||
|
||||
|
||||
generateTempMessageLink(msg: Message) {
|
||||
const a = document.createElement('a');
|
||||
a.target = "_blank";
|
||||
if (msg.media) {
|
||||
a.href = msg.media.url;
|
||||
|
||||
|
||||
if (msg._data?.mimetype?.startsWith('image/')) {
|
||||
const img = document.createElement('img');
|
||||
img.classList.add('message-image-attachement');
|
||||
|
|
@ -257,28 +264,28 @@ export const ui = {
|
|||
a.download = msg.media.filename || "file";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return a;
|
||||
},
|
||||
|
||||
|
||||
generateMessage(msg: Message, userID: string, chatId: string, isLocal: boolean = false) {
|
||||
const isOutgoing = msg.fromMe || msg.sender === 'me';
|
||||
|
||||
|
||||
function getPrevMessageElem() {
|
||||
return elements.messagesContainer.lastElementChild as HTMLElement | null;
|
||||
}
|
||||
|
||||
|
||||
const prevMsgEl = getPrevMessageElem();
|
||||
|
||||
|
||||
const groupDiv = document.createElement('div');
|
||||
groupDiv.className = `message-group selectable ${isOutgoing ? 'outgoing' : 'incoming'}`;
|
||||
groupDiv.id = normalizeId(msg._serialized ? (msg._serialized as any) : msg.id) || "msg-id";
|
||||
groupDiv.dataset.timestamp = msg.timestamp?.toString();
|
||||
groupDiv.dataset.from = msg.participant || (msg.from as string);
|
||||
|
||||
|
||||
const senderName = isOutgoing ? userID : (msg._data?.notifyName || (msg.from as string));
|
||||
const timeStr = formatTime(msg.timestamp || new Date());
|
||||
|
||||
|
||||
let statusCheck = '';
|
||||
if (isOutgoing) {
|
||||
if (msg.status === 'read') {
|
||||
|
|
@ -291,42 +298,42 @@ export const ui = {
|
|||
statusCheck = '<span class="mif-done" style="width:14px; height:14px;"></span>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'message-bubble';
|
||||
|
||||
|
||||
let prevUid: string | undefined;
|
||||
|
||||
|
||||
if (msg.participant) {
|
||||
prevUid = msg.participant;
|
||||
} else {
|
||||
prevUid = msg.from as string;
|
||||
}
|
||||
|
||||
|
||||
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: HTMLAnchorElement;
|
||||
|
||||
|
||||
if (isLocal) {
|
||||
a = this.generateTempMessageLink(msg);
|
||||
} else {
|
||||
a = document.createElement('a');
|
||||
a.innerText = `[Request media]`;
|
||||
a.target = "_blank";
|
||||
|
||||
|
||||
const clickListener = async (e: MouseEvent) => {
|
||||
a.removeEventListener('click', clickListener);
|
||||
a.innerText = `[Downloading]`;
|
||||
|
|
@ -338,44 +345,48 @@ export const ui = {
|
|||
}
|
||||
const url = new URL(mediaMsg.media.url);
|
||||
const reqID = url.pathname.split('/').filter(Boolean).pop();
|
||||
|
||||
|
||||
if (!reqID) return;
|
||||
const media = await getMedia(reqID);
|
||||
if (!media) return;
|
||||
|
||||
|
||||
const objectUrl = URL.createObjectURL(media.blob);
|
||||
(e.target as HTMLAnchorElement).href = objectUrl;
|
||||
|
||||
|
||||
if (media.blob.type.startsWith('image/')) {
|
||||
a.textContent = "";
|
||||
const img = document.createElement('img');
|
||||
img.classList.add('message-image-attachement');
|
||||
img.src = objectUrl;
|
||||
bubble.after(img);
|
||||
const content = bubble.querySelector('.message-content');
|
||||
if (content) content.remove();
|
||||
this.ensureScroll(elements.messagesContainer, () => {
|
||||
bubble.before(img);
|
||||
})
|
||||
// const content = bubble.querySelector('.message-content');
|
||||
// if (content) content.remove(); // TODO make images attachment with text look cooler
|
||||
} else {
|
||||
(e.target as HTMLAnchorElement).textContent = media.filename || `Download ${mediaMsg.media.filename}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
a.addEventListener('click', clickListener);
|
||||
}
|
||||
|
||||
contentEl.appendChild(a);
|
||||
|
||||
|
||||
this.ensureScroll(elements.messagesContainer, () => {
|
||||
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');
|
||||
|
|
@ -397,18 +408,18 @@ export const ui = {
|
|||
groupDiv.appendChild(indicator);
|
||||
} else groupDiv.classList.add('same-sender');
|
||||
}
|
||||
|
||||
|
||||
groupDiv.appendChild(bubble);
|
||||
return groupDiv;
|
||||
},
|
||||
|
||||
|
||||
updateMessage(originalMsgId: string, generatedMsg: HTMLElement) {
|
||||
const originalMsg = document.querySelector(`#${originalMsgId}`);
|
||||
if (originalMsg) {
|
||||
originalMsg.replaceWith(generatedMsg)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
updateMessageTick(id: string, status: string) {
|
||||
let statusCheck;
|
||||
if (status === 'read') {
|
||||
|
|
@ -420,20 +431,123 @@ export const ui = {
|
|||
} else {
|
||||
statusCheck = '<span class="mif-done" style="width:14px; height:14px;"></span>';
|
||||
}
|
||||
|
||||
|
||||
const msgNode = document.getElementById(id);
|
||||
if (msgNode) {
|
||||
const meta = msgNode.querySelector('.message-meta');
|
||||
if (meta) meta.outerHTML = statusCheck;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
toggleChatBottomBar() {
|
||||
elements.chatBottomBar.classList.toggle("collapsed");
|
||||
},
|
||||
|
||||
|
||||
removeChatMessage(msgId: string) {
|
||||
const message = document.getElementById(msgId);
|
||||
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));
|
||||
})
|
||||
|
|
@ -79,11 +79,11 @@ export const waha = {
|
|||
chatId = chatId._serialized || chatId.user || JSON.stringify(chatId);
|
||||
}
|
||||
return {
|
||||
id: chatId || chat.chatId || chat.name,
|
||||
name: chat.name || "Unknown Contact",
|
||||
id: chatId || chat.chatId || null,
|
||||
name: chat.name || null,
|
||||
unreadCount: chat.unreadCount || 0,
|
||||
lastMessage: chat.lastMessage?.body || chat.lastMessageText || "Click to open chat",
|
||||
timestamp: chat.lastMessage?.timestamp || new Date()
|
||||
lastMessage: chat.lastMessage?.body || chat.lastMessageText || null,
|
||||
timestamp: chat.lastMessage?.timestamp || null
|
||||
};
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -93,9 +93,6 @@ html, body {
|
|||
body {
|
||||
background-color: var(--bg-main);
|
||||
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 {
|
||||
|
|
@ -129,16 +126,44 @@ input:focus {
|
|||
|
||||
.app-container {
|
||||
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%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
overflow-x: auto;
|
||||
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 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dim {
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
/* Sidebar Styling */
|
||||
.sidebar {
|
||||
width: 380px;
|
||||
|
|
@ -237,10 +262,6 @@ input:focus {
|
|||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
#chat-bottom-bar .icon-btn:hover::before {
|
||||
color: var(--bg-main);
|
||||
}
|
||||
|
||||
.icon-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
|
@ -444,6 +465,7 @@ input:focus {
|
|||
.api-status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
|
|
@ -480,6 +502,7 @@ input:focus {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
|
|
@ -847,8 +870,14 @@ input:focus {
|
|||
|
||||
/* Extra Pages */
|
||||
.extra-page {
|
||||
display: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
flex: 0 0 100%;
|
||||
|
||||
}
|
||||
|
||||
.extra-page-content {
|
||||
|
|
@ -875,7 +904,7 @@ input:focus {
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
#profile-page h2 {
|
||||
.big-title {
|
||||
padding: 1.4rem;
|
||||
font-size: 5rem;
|
||||
text-wrap-mode: nowrap;
|
||||
|
|
@ -935,13 +964,12 @@ input:focus {
|
|||
scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
/* Hide scrollbars */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.app-container::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari, Opera */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
|
|
@ -969,7 +997,7 @@ input:focus {
|
|||
}
|
||||
|
||||
.extra-page {
|
||||
display: block;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex: 0 0 100%;
|
||||
position: relative;
|
||||
|
|
@ -1018,7 +1046,6 @@ input:focus {
|
|||
.modal-content {
|
||||
width: 100%;
|
||||
background: var(--bg-main);
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
display: flex;
|
||||
|
|
@ -1035,7 +1062,7 @@ input:focus {
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 1em 2em 0em 2em;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
|
|
@ -1047,6 +1074,11 @@ input:focus {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
overflow-x: hidden;
|
||||
padding: 0 2rem 0 2rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
|
|
@ -1071,7 +1103,7 @@ input:focus {
|
|||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: auto;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
|
|
@ -1084,8 +1116,15 @@ input:focus {
|
|||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
background: transparent;
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue