Compare commits
10 changed files with 134 additions and 398 deletions
32
web/app.html
32
web/app.html
|
|
@ -14,26 +14,12 @@
|
|||
<link rel="manifest" href="./manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading-screen">
|
||||
<h1>Pandora</h1>
|
||||
<p id="loading-screen-status">Please wait...</p>
|
||||
<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>
|
||||
<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" title="Profile"></button>
|
||||
<button id="chats-sidebar-btn" class="icon-btn mif-qa mif-2x" data-page="chat-page" title="Chat"></button>
|
||||
<button id="settings-sidebar-btn" class="icon-btn mif-cog mif-2x" data-page="settings-page" title="Settings"></button>
|
||||
<img data-resource="app-user-image">
|
||||
<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 -->
|
||||
|
|
@ -106,15 +92,13 @@
|
|||
</div>
|
||||
|
||||
<!-- Input Panel -->
|
||||
<div id="mentioning-indicator" class="collapsed">
|
||||
</div>
|
||||
<footer id="chat-input-panel" class="chat-input-panel">
|
||||
<div class="input-actions-left">
|
||||
<button id="chat-bottom-bar-btn" class="chat-footer-btn icon-btn mif-expand-less mif-3x" title="Expand"></button>
|
||||
<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">
|
||||
<textarea type="text" id="message-input" rows="1" placeholder="Type a message..." autocomplete="off"></textarea>
|
||||
<button type="submit" class="chat-footer-btn send-btn mif-paper-plane mif-3x" id="send-button">
|
||||
<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>
|
||||
|
|
@ -129,9 +113,7 @@
|
|||
</div>
|
||||
|
||||
<section class="extra-page" id="profile-page">
|
||||
<div class="modal-header">
|
||||
<h2 class="big-title" data-content="app-user">Pandora User</h2>
|
||||
</div>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -13,9 +13,7 @@
|
|||
<body>
|
||||
<div class="app-container _scrollableView">
|
||||
<section class="extra-page" id="welcome-page" style="display: flex;">
|
||||
<div class="modal-header">
|
||||
<h2 class="big-title" style="margin: 0 auto">Pandora</h2>
|
||||
</div>
|
||||
<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">
|
||||
|
|
@ -58,9 +56,7 @@
|
|||
</div>
|
||||
</section>
|
||||
<section class="extra-page" id="connecting-page">
|
||||
<div class="modal-header">
|
||||
<h2 class="big-title" style="margin: 0 auto">Connecting</h2>
|
||||
</div>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ import { fetchChats, getAppUser, getChatMessages, getChatPicture, getChats, getU
|
|||
import { deleteDatabase, upsertMessages } from "./db";
|
||||
import { showNotification } from "./notification";
|
||||
import type { Chat, Message, WebSocketEvent } from "./types";
|
||||
import { activeChatState, clearMentionCache, mentionCacheID, mentionCacheText, setActiveChatState } from "./states";
|
||||
if (localStorage.getItem('setupComplete') !== "true") window.location.href = "index.html";
|
||||
import { activeChatState, setActiveChatState } from "./states";
|
||||
|
||||
const messageTone = new Audio("./message.ogg");
|
||||
const longPressEvent = new CustomEvent("longpress");
|
||||
|
|
@ -19,41 +18,29 @@ if (!mainViewEl) throw console.error();
|
|||
const mainView = views.get(mainViewEl);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
ui.load(async () => {
|
||||
ui.loadingMessage("Drawing sidebar...");
|
||||
updateSidebarPosition();
|
||||
ui.loadingMessage("Asking for notification permission...");
|
||||
askForNotificationPermission();
|
||||
ui.loadingMessage("Loading configuration...");
|
||||
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}`);
|
||||
}
|
||||
ui.loadingMessage("Getting server version...");
|
||||
await updateOnlineStatus();
|
||||
ui.loadingMessage("Setting up event listeners...");
|
||||
setupEventListeners();
|
||||
try {
|
||||
ui.loadingMessage("Replacing placeholders...");
|
||||
await setupElementsData();
|
||||
ui.loadingMessage("Loading chats...");
|
||||
await loadChats();
|
||||
ui.loadingMessage("Checking server status...");
|
||||
await checkWahaStatus();
|
||||
ui.loadingMessage("Telling server to send new messages...");
|
||||
await initWebSocket();
|
||||
} finally {
|
||||
elements.chatsLoader.classList.add('hidden');
|
||||
}
|
||||
})
|
||||
updateSidebarPosition();
|
||||
askForNotificationPermission();
|
||||
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 {
|
||||
setupElementsData();
|
||||
loadChats();
|
||||
checkWahaStatus();
|
||||
initWebSocket();
|
||||
} finally {
|
||||
elements.chatsLoader.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
async function askForNotificationPermission() {
|
||||
notificationAuthorization = await Notification.requestPermission();
|
||||
}
|
||||
|
|
@ -144,7 +131,7 @@ function scrollToList(smooth = true) {
|
|||
|
||||
function setupEventListeners() {
|
||||
window.addEventListener('resize', updateSidebarPosition);
|
||||
|
||||
|
||||
if (!window.location.hash) {
|
||||
window.location.hash = '';
|
||||
}
|
||||
|
|
@ -203,27 +190,16 @@ function setupEventListeners() {
|
|||
ui.renderChatList(filtered, activeChatState, selectChat);
|
||||
});
|
||||
|
||||
ui.autoResizeTextArea(elements.messageInput);
|
||||
|
||||
elements.messageForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
});
|
||||
|
||||
elements.messageInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
elements.chatBottomBar.style.height = `${elements.chatInputPanel.offsetHeight}px`;
|
||||
const observer = new ResizeObserver(() => {
|
||||
elements.chatBottomBar.style.height =
|
||||
`${elements.chatInputPanel.offsetHeight}px`;
|
||||
});
|
||||
|
||||
elements.mentioningIndicator.addEventListener('click', clearMentionCache);
|
||||
|
||||
observer.observe(elements.chatInputPanel);
|
||||
elements.chatBottomBarBtn.addEventListener('click', ui.toggleChatBottomBar);
|
||||
|
|
@ -337,9 +313,9 @@ async function handleIncomingMessage(msg: Message) {
|
|||
|
||||
if (!msg.fromMe) {
|
||||
messageTone.play();
|
||||
if (notificationAuthorization === "granted") {
|
||||
new Notification("New message", { body: msg.body || msg.text });
|
||||
}
|
||||
}
|
||||
if (notificationAuthorization === "granted") {
|
||||
new Notification("New message", { body: msg.body || msg.text });
|
||||
}
|
||||
|
||||
if (activeChatState && activeChatState.id === msgChatId) {
|
||||
|
|
@ -358,12 +334,6 @@ async function handleIncomingMessage(msg: Message) {
|
|||
|
||||
async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) {
|
||||
if (isLoadingChat) return;
|
||||
|
||||
clearMentionCache();
|
||||
|
||||
const pageEl = document.getElementById("chat-page");
|
||||
if (!pageEl) return;
|
||||
mainView?.scrollTo(pageEl);
|
||||
|
||||
isLoadingChat = true;
|
||||
setActiveChatState(chat);
|
||||
|
|
@ -438,10 +408,7 @@ async function sendMessage() {
|
|||
fromMe: true,
|
||||
sender: 'me',
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'sending',
|
||||
replyTo: mentionCacheID ? {
|
||||
body: mentionCacheText || "Mention (no text)"
|
||||
} : null
|
||||
status: 'sending'
|
||||
} as any;
|
||||
|
||||
ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id);
|
||||
|
|
@ -470,7 +437,7 @@ async function sendMessage() {
|
|||
console.warn('readChat failed (non-fatal):', e.message);
|
||||
}
|
||||
|
||||
const responseData = await waha.sendTextMessage(activeChatState.id, text, mentionCacheID);
|
||||
const responseData = await waha.sendTextMessage(activeChatState.id, text);
|
||||
|
||||
const tempBubble = document.getElementById(tempMsg.id);
|
||||
if (tempBubble) {
|
||||
|
|
@ -491,8 +458,6 @@ async function sendMessage() {
|
|||
if (meta) meta.innerHTML = `<span style="color: #ef4444;">Failed to send</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
clearMentionCache();
|
||||
}
|
||||
|
||||
async function sendFileMessage(file: File) {
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
export class Parser {
|
||||
input: string;
|
||||
|
||||
constructor(input: string) {
|
||||
this.input = input;
|
||||
}
|
||||
|
||||
parse(token: string, to: string): Parser {
|
||||
const esc = Parser.escapeRegex(token);
|
||||
|
||||
const regex = new RegExp(`(^|\\s)${esc}(.+?)${esc}(?=\\s|$)`, "gs");
|
||||
|
||||
this.input = this.input.replace(regex, (_match, pre, inner) => {
|
||||
return `${pre}${to.replace("$1", inner)}`;
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
replace(searchValue: string, replaceValue: string): Parser {
|
||||
this.input = this.input.replaceAll(searchValue, replaceValue);
|
||||
return this;
|
||||
}
|
||||
|
||||
static escapeRegex(string: string) {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +1,7 @@
|
|||
import { Chat } from "./types";
|
||||
import { elements } from "./ui";
|
||||
|
||||
export let activeChatState: Chat | null = null;
|
||||
export let mentionCacheID: string | null = null;
|
||||
export let mentionCacheText: string | null = null;
|
||||
|
||||
export function setActiveChatState(value: Chat | null) {
|
||||
activeChatState = value;
|
||||
}
|
||||
|
||||
export function prepareMention(id: string, text: string) {
|
||||
mentionCacheID = id;
|
||||
mentionCacheText = text;
|
||||
elements.mentioningIndicator.innerText = `Mentioning "${text}".`;
|
||||
elements.mentioningIndicator.classList.remove('collapsed');
|
||||
}
|
||||
|
||||
export function clearMentionCache() {
|
||||
mentionCacheID = null;
|
||||
mentionCacheText = null;
|
||||
elements.mentioningIndicator.innerText = ``;
|
||||
elements.mentioningIndicator.classList.add('collapsed');
|
||||
}
|
||||
|
|
@ -52,7 +52,6 @@ export interface Message {
|
|||
chatId?: string;
|
||||
chat?: { id: string };
|
||||
participant?: string;
|
||||
replyTo?: Message;
|
||||
}
|
||||
|
||||
/** Temporary message used for optimistic UI updates */
|
||||
|
|
|
|||
106
web/src/ui.ts
106
web/src/ui.ts
|
|
@ -1,8 +1,7 @@
|
|||
import { formatTime, normalizeId } from "./utils";
|
||||
import { getChatPicture, getMessage, getMedia, getMoreChatMessages } from "./storage";
|
||||
import type { Chat, Message } from "./types";
|
||||
import { activeChatState, prepareMention } from "./states";
|
||||
import { Parser } from "./parser";
|
||||
import { activeChatState } from "./states";
|
||||
|
||||
export const elements = {
|
||||
chatList: document.getElementById('chat-list') as HTMLUListElement,
|
||||
|
|
@ -16,7 +15,7 @@ export const elements = {
|
|||
activeChatAvatar: document.getElementById('active-chat-avatar') as HTMLDivElement,
|
||||
messagesContainer: document.getElementById('messages-container') as HTMLDivElement,
|
||||
messageForm: document.getElementById('message-form') as HTMLFormElement,
|
||||
messageInput: document.getElementById('message-input') as HTMLTextAreaElement,
|
||||
messageInput: document.getElementById('message-input') as HTMLInputElement,
|
||||
backToSidebarBtn: document.querySelector('.chat-header') as HTMLElement,
|
||||
sidebar: document.querySelector('.sidebar') as HTMLElement,
|
||||
appContainer: document.querySelector('.app-container') as HTMLElement,
|
||||
|
|
@ -46,10 +45,7 @@ export const elements = {
|
|||
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>,
|
||||
loadingScreen: document.querySelector('#loading-screen') as HTMLElement,
|
||||
loadingScreenStatus: document.querySelector('#loading-screen-status') as HTMLElement,
|
||||
mentioningIndicator: document.querySelector('#mentioning-indicator') as HTMLElement
|
||||
scrollableViews: document.querySelectorAll('._scrollableView') as NodeListOf<HTMLElement>
|
||||
};
|
||||
|
||||
export const views = new Map<HTMLElement, ScrollableView>;
|
||||
|
|
@ -241,10 +237,7 @@ export const ui = {
|
|||
msgs.shift();
|
||||
|
||||
msgs.forEach(async msg => {
|
||||
const message = this.generateMessage(msg, userId, chatId);
|
||||
if (message) {
|
||||
loadMoreButton.after();
|
||||
}
|
||||
loadMoreButton.after(this.generateMessage(msg, userId, chatId));
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -252,10 +245,7 @@ export const ui = {
|
|||
* Append a single message (used for optimistic updates immediately upon sending)
|
||||
*/
|
||||
appendSingleMessage(msg: Message, userID: string, chatId: string, isLocal: boolean = false) {
|
||||
const message = this.generateMessage(msg, userID, chatId, isLocal);
|
||||
if (message) {
|
||||
elements.messagesContainer.appendChild(message)
|
||||
}
|
||||
elements.messagesContainer.appendChild(this.generateMessage(msg, userID, chatId, isLocal))
|
||||
},
|
||||
|
||||
generateTempMessageLink(msg: Message) {
|
||||
|
|
@ -279,7 +269,6 @@ export const ui = {
|
|||
},
|
||||
|
||||
generateMessage(msg: Message, userID: string, chatId: string, isLocal: boolean = false) {
|
||||
if (msg._data && msg._data.type == "gp2") return; // probably group description edit
|
||||
const isOutgoing = msg.fromMe || msg.sender === 'me';
|
||||
|
||||
function getPrevMessageElem() {
|
||||
|
|
@ -291,7 +280,6 @@ export const ui = {
|
|||
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.id = msg.id.toString();
|
||||
groupDiv.dataset.timestamp = msg.timestamp?.toString();
|
||||
groupDiv.dataset.from = msg.participant || (msg.from as string);
|
||||
|
||||
|
|
@ -331,42 +319,8 @@ export const ui = {
|
|||
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.classList.add('message-content');
|
||||
|
||||
if (msg.replyTo) {
|
||||
const replyTo = msg.replyTo;
|
||||
const replyIndicatorEl = document.createElement("div");
|
||||
replyIndicatorEl.classList.add('reply-indicator');
|
||||
replyIndicatorEl.textContent = new Parser(replyTo.body || replyTo.text || "")
|
||||
.parse('_', '<i>$1</i>')
|
||||
.parse('*', '<b>$1</b>')
|
||||
.parse('~', '<s>$1</s>')
|
||||
.parse('```', '<span style="font-family: monospace;">$1</span>')
|
||||
.parse('`', '<code>$1</code>')
|
||||
.replace("\n", "<br>")
|
||||
.input;
|
||||
|
||||
replyIndicatorEl.addEventListener('click', () => {
|
||||
const _msg = document.querySelector(`[id*="${replyTo.id}"]`) as HTMLElement;
|
||||
if (_msg) {
|
||||
_msg.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
this.tempClass(_msg, "mentioned-highlight", 1000);
|
||||
}
|
||||
});
|
||||
|
||||
bubble.appendChild(replyIndicatorEl);
|
||||
}
|
||||
|
||||
const textEl = document.createElement('div');
|
||||
const parsed = new Parser(msg.body || msg.text || "")
|
||||
.parse('_', '<i>$1</i>')
|
||||
.parse('*', '<b>$1</b>')
|
||||
.parse('~', '<s>$1</s>')
|
||||
.parse('```', '<span style="font-family: monospace;">$1</span>')
|
||||
.parse('`', '<code>$1</code>')
|
||||
.replace("\n", "<br>")
|
||||
.input;
|
||||
|
||||
textEl.innerHTML = parsed;
|
||||
textEl.innerHTML = msg.body || msg.text || "";
|
||||
contentEl.appendChild(textEl);
|
||||
bubble.appendChild(contentEl);
|
||||
|
||||
|
|
@ -400,7 +354,6 @@ export const ui = {
|
|||
(e.target as HTMLAnchorElement).href = objectUrl;
|
||||
|
||||
if (media.blob.type.startsWith('image/')) {
|
||||
groupDiv.classList.add('image');
|
||||
a.textContent = "";
|
||||
const img = document.createElement('img');
|
||||
img.classList.add('message-image-attachement');
|
||||
|
|
@ -417,7 +370,7 @@ export const ui = {
|
|||
|
||||
a.addEventListener('click', clickListener);
|
||||
}
|
||||
|
||||
|
||||
this.ensureScroll(elements.messagesContainer, () => {
|
||||
contentEl.appendChild(a);
|
||||
});
|
||||
|
|
@ -427,6 +380,7 @@ export const ui = {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'message-meta';
|
||||
meta.innerHTML = `<span>${timeStr}</span>${statusCheck}`;
|
||||
|
|
@ -454,10 +408,6 @@ export const ui = {
|
|||
groupDiv.appendChild(indicator);
|
||||
} else groupDiv.classList.add('same-sender');
|
||||
}
|
||||
|
||||
bubble.addEventListener('dblclick', () => {
|
||||
prepareMention(msg.id.toString(), parsed);
|
||||
});
|
||||
|
||||
groupDiv.appendChild(bubble);
|
||||
return groupDiv;
|
||||
|
|
@ -497,46 +447,6 @@ export const ui = {
|
|||
const message = document.getElementById(msgId);
|
||||
if (message) message.remove();
|
||||
},
|
||||
|
||||
autoResizeTextArea(element: HTMLTextAreaElement) {
|
||||
const resize = () => {
|
||||
element.style.height = 'auto';
|
||||
const offset = element.offsetHeight - element.clientHeight;
|
||||
const newHeight = element.scrollHeight + offset;
|
||||
|
||||
if (element.scrollHeight > 0) {
|
||||
element.style.height = `${newHeight}px`;
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('input', resize);
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
resize();
|
||||
}
|
||||
});
|
||||
observer.observe(element);
|
||||
},
|
||||
|
||||
async load(fn: Function) {
|
||||
this.loadingMessage("Please wait...");
|
||||
elements.loadingScreen.classList.remove('collapsed');
|
||||
await fn();
|
||||
elements.loadingScreen.classList.add('collapsed');
|
||||
this.loadingMessage("Done!");
|
||||
},
|
||||
|
||||
loadingMessage(message: string) {
|
||||
elements.loadingScreenStatus.innerText = message;
|
||||
},
|
||||
|
||||
tempClass(element: HTMLElement, clazz: string, time: number) {
|
||||
element.classList.add(clazz);
|
||||
setTimeout(() => {
|
||||
element.classList.remove(clazz);
|
||||
}, time)
|
||||
}
|
||||
};
|
||||
|
||||
export class ScrollableView {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import type { Message, MessageWithTime } from "./types";
|
||||
|
||||
/**
|
||||
* Format timestamps (supports Unix epoch seconds/ms, strings and ISO dates)
|
||||
* @param {string|number|Date} dateVal
|
||||
* @returns {string} Formatted HH:MM AM/PM string
|
||||
*/
|
||||
export function formatTime(dateVal: string | number | Date): string {
|
||||
if (!dateVal) return '';
|
||||
let date: Date;
|
||||
|
|
@ -21,6 +26,12 @@ export function formatTime(dateVal: string | number | Date): string {
|
|||
return `${hours}:${minutesStr} ${ampm}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust outgoing messages backdated behind incoming messages due to clock drift.
|
||||
* Uses a 30-second sliding bubble-sort window.
|
||||
* @param {Array} messages List of raw messages from WAHA
|
||||
* @returns {Array} Compensated chronological message array
|
||||
*/
|
||||
export function compensateMessageOrdering(messages: Message[]): Message[] {
|
||||
if (!Array.isArray(messages)) return [];
|
||||
|
||||
|
|
@ -37,6 +48,32 @@ export function compensateMessageOrdering(messages: Message[]): Message[] {
|
|||
|
||||
msgs.sort((a, b) => a._time - b._time);
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (let i = 0; i < msgs.length - 1; i++) {
|
||||
const current = msgs[i];
|
||||
const next = msgs[i + 1];
|
||||
const timeDiff = next._time - current._time;
|
||||
|
||||
// swap if outgoing message is sorted before incoming message within 30-sec window
|
||||
if (current.fromMe && !next.fromMe && timeDiff >= 0 && timeDiff <= 30000) {
|
||||
msgs[i] = next;
|
||||
msgs[i + 1] = current;
|
||||
|
||||
// advance the outgoing message timestamp to exactly 1 sec after the incoming
|
||||
current._time = next._time + 1000;
|
||||
if (typeof current.timestamp === 'number') {
|
||||
current.timestamp = Math.floor(current._time / 1000);
|
||||
} else {
|
||||
current.timestamp = new Date(current._time).toISOString();
|
||||
}
|
||||
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clean temp property
|
||||
return msgs.map(({ _time, ...m }) => m);
|
||||
}
|
||||
|
|
@ -62,6 +99,11 @@ export function getBase64(file: File): Promise<string> {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a WhatsApp ID (chatId, messageId, etc.) to its string representation
|
||||
* @param {string|object} raw
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeId(raw: string | { _serialized?: string; user?: string } | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === 'object') {
|
||||
|
|
|
|||
|
|
@ -138,14 +138,13 @@ export const waha = {
|
|||
});
|
||||
},
|
||||
|
||||
async sendTextMessage(chatId: string, text: string, replyTo: string | null = null): Promise<Message> {
|
||||
async sendTextMessage(chatId: string, text: string): Promise<Message> {
|
||||
return request<Message>('/api/sendText', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
chatId,
|
||||
text,
|
||||
session: config.session,
|
||||
replyTo: replyTo
|
||||
session: config.session
|
||||
})
|
||||
});
|
||||
},
|
||||
|
|
|
|||
202
web/style.css
202
web/style.css
|
|
@ -1,36 +1,35 @@
|
|||
@font-face {
|
||||
font-family: Selawik;
|
||||
src: url(./fonts/selawk.ttf);
|
||||
font-weight: normal;
|
||||
font-family: Selawik;
|
||||
src: url(./fonts/selawk.ttf);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Selawik;
|
||||
src: url(./fonts/selawkl.ttf);
|
||||
font-weight: 200;
|
||||
font-family: Selawik;
|
||||
src: url(./fonts/selawkl.ttf);
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Selawik;
|
||||
src: url(./fonts/selawksl.ttf);
|
||||
font-weight: 300;
|
||||
font-family: Selawik;
|
||||
src: url(./fonts/selawksl.ttf);
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Selawik;
|
||||
src: url(./fonts/selawkb.ttf);
|
||||
font-weight: bold;
|
||||
font-family: Selawik;
|
||||
src: url(./fonts/selawkb.ttf);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Selawik;
|
||||
src: url(./fonts/selawkb.ttf);
|
||||
font-weight: 800;
|
||||
font-family: Selawik;
|
||||
src: url(./fonts/selawkb.ttf);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-main: black;
|
||||
--bg-primary: black;
|
||||
--bg-secondary: #242424;
|
||||
--sidebar-bg: black;
|
||||
--chat-bg: black;
|
||||
|
|
@ -51,9 +50,6 @@
|
|||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.1);
|
||||
--glass-blur: blur(20px);
|
||||
--high-box-shadow: 2px 7px 5px rgba(0, 0, 0, 0.3), 0px -4px 10px rgba(0, 0, 0, 0.3);
|
||||
--page-padding: 1.6rem;
|
||||
--page-padding-small: 1rem;
|
||||
--container-padding: .4rem;
|
||||
}
|
||||
|
||||
body.light {
|
||||
|
|
@ -128,10 +124,6 @@ input:focus {
|
|||
outline: none;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-inline-start: 1em;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
@ -187,8 +179,7 @@ li {
|
|||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: var(--page-padding);
|
||||
padding-bottom: 0;
|
||||
padding: 20px 20px 0px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: self-start;
|
||||
|
|
@ -304,6 +295,7 @@ li {
|
|||
|
||||
#chat-search {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.25s ease;
|
||||
|
|
@ -324,8 +316,7 @@ li {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: var(--container-padding);
|
||||
padding-top: 0;
|
||||
padding: 0 10px 10px 10px;
|
||||
}
|
||||
|
||||
.chat-list-header {
|
||||
|
|
@ -364,22 +355,21 @@ li {
|
|||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid transparent;
|
||||
margin-left: 0px;
|
||||
}
|
||||
|
||||
.chat-item.selected {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transform: scale(0.98) skewX(10deg);
|
||||
filter: blur(.1px);
|
||||
opacity: .6;
|
||||
.selected {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transform: scale(0.98) skewX(10deg);
|
||||
filter: blur(.1px);
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
|
||||
.chat-item.selectable:active {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transform: scale(0.98) skewX(10deg);
|
||||
filter: blur(.1px);
|
||||
opacity: .6;
|
||||
.selectable:active {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
transform: scale(0.98) skewX(10deg);
|
||||
filter: blur(.1px);
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
.chat-item-info {
|
||||
|
|
@ -471,19 +461,7 @@ li {
|
|||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: .6em .2em;
|
||||
width: 60px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#desktop-aside * {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
#desktop-aside img {
|
||||
margin: .2rem;
|
||||
margin-top: auto;
|
||||
padding: .2em;
|
||||
}
|
||||
|
||||
/* Sidebar Footer */
|
||||
|
|
@ -578,7 +556,7 @@ li {
|
|||
flex-direction: column;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
|
|
@ -612,7 +590,7 @@ li {
|
|||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
|
||||
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0.75),
|
||||
|
|
@ -658,7 +636,7 @@ li {
|
|||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
padding-bottom: 0rem;
|
||||
padding-bottom: .6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
user-select: text;
|
||||
|
|
@ -688,10 +666,6 @@ li {
|
|||
transition: .1s;
|
||||
}
|
||||
|
||||
.message-group.image {
|
||||
max-width: 30%;
|
||||
}
|
||||
|
||||
.message-group.incoming {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
|
@ -771,6 +745,7 @@ li {
|
|||
|
||||
.message-image-attachement {
|
||||
max-width: 100%;
|
||||
max-height: 10em;
|
||||
}
|
||||
|
||||
/* Chat Input Panel */
|
||||
|
|
@ -808,13 +783,7 @@ li {
|
|||
background-color: transparent;
|
||||
}
|
||||
|
||||
.alternate-panel button:hover {
|
||||
filter: invert(1);
|
||||
background-color: var(--bg-primary);
|
||||
}
|
||||
|
||||
|
||||
.alternate-panel input:hover {
|
||||
.alternate-panel button:hover, .alternate-panel input:hover {
|
||||
border-color: var(--bg-primary);
|
||||
background-color: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
|
|
@ -839,18 +808,13 @@ li {
|
|||
|
||||
#message-input {
|
||||
flex: 1;
|
||||
overflow-y: scroll;
|
||||
padding: 12px 18px;
|
||||
background-color: var(--text-primary);
|
||||
border: medium solid var(--border-color);
|
||||
color: var(--bg-main);
|
||||
font-size: 0.95rem;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 0;
|
||||
resize: none;
|
||||
line-height: 1.3;
|
||||
max-height: 6rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#message-input:focus {
|
||||
|
|
@ -916,7 +880,7 @@ li {
|
|||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
flex: 0 0 100%;
|
||||
|
||||
|
||||
}
|
||||
|
||||
.extra-page-content {
|
||||
|
|
@ -934,7 +898,7 @@ li {
|
|||
}
|
||||
|
||||
#profile-page .content {
|
||||
padding: var(--page-padding);
|
||||
padding: 1.4rem;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
}
|
||||
|
|
@ -944,6 +908,7 @@ li {
|
|||
}
|
||||
|
||||
.big-title {
|
||||
padding: 1.4rem;
|
||||
font-size: 5rem;
|
||||
text-wrap-mode: nowrap;
|
||||
overflow: hidden;
|
||||
|
|
@ -991,73 +956,6 @@ li {
|
|||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.mentioned-highlight {
|
||||
transform: translateX(2em);
|
||||
}
|
||||
|
||||
.mentioned-highlight .message-bubble {
|
||||
background-color: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
}
|
||||
|
||||
.outgoing.mentioned-highlight {
|
||||
transform: translateX(-2em);
|
||||
}
|
||||
|
||||
.reply-indicator {
|
||||
opacity: .7;
|
||||
max-width: 10em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.reply-indicator::before {
|
||||
content: "> ";
|
||||
}
|
||||
|
||||
#loading-screen {
|
||||
transition: .8s;
|
||||
height: 100dvh;
|
||||
width: 100dvw;
|
||||
z-index: 999;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: var(--bg-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
outline: thick solid var(--bg-secondary);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#loading-screen.collapsed {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#mentioning-indicator {
|
||||
transition: all .4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 0.4rem 0.8rem;
|
||||
max-height: 3em;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
margin-top: .8rem;
|
||||
}
|
||||
|
||||
#mentioning-indicator.collapsed {
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
margin-top: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
width: 100%;
|
||||
|
|
@ -1101,11 +999,6 @@ li {
|
|||
scroll-snap-stop: always;
|
||||
}
|
||||
|
||||
#message-input {
|
||||
padding: 8px 12px;
|
||||
max-height: 10rem;
|
||||
}
|
||||
|
||||
.extra-page {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
|
@ -1130,10 +1023,6 @@ li {
|
|||
max-width: 90%;
|
||||
}
|
||||
|
||||
.message-group.image {
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.message-image-attachement {
|
||||
max-height: 20em;
|
||||
}
|
||||
|
|
@ -1141,16 +1030,16 @@ li {
|
|||
#desktop-aside {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
#profile-page .content {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
|
||||
#profile-page-picture {
|
||||
max-width: 50%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
|
||||
#profile-page-user-number {
|
||||
text-align: center;
|
||||
}
|
||||
|
|
@ -1165,7 +1054,6 @@ li {
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.modal-body h3 {
|
||||
|
|
@ -1177,10 +1065,10 @@ li {
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--page-padding) var(--page-padding) 0em var(--page-padding);
|
||||
padding: 1em 2em 0em 2em;
|
||||
}
|
||||
|
||||
h2.modal-title {
|
||||
.modal-header h2 {
|
||||
font-size: xx-large;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -1193,7 +1081,7 @@ h2.modal-title {
|
|||
overflow-y: auto;
|
||||
flex: 1;
|
||||
overflow-x: hidden;
|
||||
padding: 0 var(--page-padding) 0 var(--page-padding);
|
||||
padding: 0 2rem 0 2rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
|
|
@ -1218,7 +1106,7 @@ h2.modal-title {
|
|||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: var(--page-padding-small) var(--page-padding);
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
|
|
|
|||
Loading…
Reference in a new issue