Compare commits

...
10 changed files with 398 additions and 134 deletions

View file

@ -14,12 +14,26 @@
<link rel="manifest" href="./manifest.json"> <link rel="manifest" href="./manifest.json">
</head> </head>
<body> <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="app-container no-active-chat">
<div class="main-holder"> <div class="main-holder">
<aside id="desktop-aside"> <aside id="desktop-aside">
<button id="profile-sidebar-btn" class="icon-btn mif-person mif-2x" data-page="profile-page"></button> <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"></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"></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">
</aside> </aside>
<!-- Sidebar --> <!-- Sidebar -->
@ -92,13 +106,15 @@
</div> </div>
<!-- Input Panel --> <!-- Input Panel -->
<div id="mentioning-indicator" class="collapsed">
</div>
<footer id="chat-input-panel" class="chat-input-panel"> <footer id="chat-input-panel" class="chat-input-panel">
<div class="input-actions-left"> <div class="input-actions-left">
<button id="chat-bottom-bar-btn" class="icon-btn mif-expand-less mif-3x" title="Expand"></button> <button id="chat-bottom-bar-btn" class="chat-footer-btn icon-btn mif-expand-less mif-3x" title="Expand"></button>
</div> </div>
<form class="input-form" id="message-form"> <form class="input-form" id="message-form">
<input type="text" id="message-input" placeholder="Type a message..." autocomplete="off"> <textarea type="text" id="message-input" rows="1" placeholder="Type a message..." autocomplete="off"></textarea>
<button type="submit" class="send-btn mif-paper-plane mif-3x" id="send-button"> <button type="submit" class="chat-footer-btn send-btn mif-paper-plane mif-3x" id="send-button">
</button> </button>
</form> </form>
</footer> </footer>
@ -113,7 +129,9 @@
</div> </div>
<section class="extra-page" id="profile-page"> <section class="extra-page" id="profile-page">
<h2 class="big-title" data-content="app-user">Pandora User</h2> <div class="modal-header">
<h2 class="big-title" data-content="app-user">Pandora User</h2>
</div>
<div class="content"> <div class="content">
<img id="profile-page-picture" data-resource="app-user-image"> <img id="profile-page-picture" data-resource="app-user-image">
<div id="profile-page-user-info"> <div id="profile-page-user-info">

View file

@ -13,7 +13,9 @@
<body> <body>
<div class="app-container _scrollableView"> <div class="app-container _scrollableView">
<section class="extra-page" id="welcome-page" style="display: flex;"> <section class="extra-page" id="welcome-page" style="display: flex;">
<h2 class="big-title" style="margin: 0 auto">Pandora</h2> <div class="modal-header">
<h2 class="big-title" style="margin: 0 auto">Pandora</h2>
</div>
<div class="modal-content"> <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 style="font-size: 20rem; margin: 0 0 auto auto; transform: translateX(40%); opacity: .4;" class="mif-qa mif"></div>
<div class="modal-body"> <div class="modal-body">
@ -56,7 +58,9 @@
</div> </div>
</section> </section>
<section class="extra-page" id="connecting-page"> <section class="extra-page" id="connecting-page">
<h2 class="big-title" style="margin: 0 auto">Connecting</h2> <div class="modal-header">
<h2 class="big-title" style="margin: 0 auto">Connecting</h2>
</div>
<div class="modal-content"> <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 style="font-size: 20rem; margin: 0 0 auto auto; transform: translateX(40%); opacity: .4;" class="mif-broadcast mif"></div>
<div class="modal-body"> <div class="modal-body">

View file

@ -7,7 +7,8 @@ import { fetchChats, getAppUser, getChatMessages, getChatPicture, getChats, getU
import { deleteDatabase, upsertMessages } from "./db"; import { deleteDatabase, upsertMessages } from "./db";
import { showNotification } from "./notification"; import { showNotification } from "./notification";
import type { Chat, Message, WebSocketEvent } from "./types"; import type { Chat, Message, WebSocketEvent } from "./types";
import { activeChatState, setActiveChatState } from "./states"; import { activeChatState, clearMentionCache, mentionCacheID, mentionCacheText, setActiveChatState } from "./states";
if (localStorage.getItem('setupComplete') !== "true") window.location.href = "index.html";
const messageTone = new Audio("./message.ogg"); const messageTone = new Audio("./message.ogg");
const longPressEvent = new CustomEvent("longpress"); const longPressEvent = new CustomEvent("longpress");
@ -18,29 +19,41 @@ if (!mainViewEl) throw console.error();
const mainView = views.get(mainViewEl); const mainView = views.get(mainViewEl);
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
updateSidebarPosition(); ui.load(async () => {
askForNotificationPermission(); ui.loadingMessage("Drawing sidebar...");
if (elements.inputApiKey) elements.inputApiKey.value = config.apiKey; updateSidebarPosition();
if (elements.inputWahaUrl) elements.inputWahaUrl.value = config.wahaUrl; ui.loadingMessage("Asking for notification permission...");
if (elements.inputSession) elements.inputSession.value = config.session; askForNotificationPermission();
if (elements.inputBackgroundImage) elements.inputBackgroundImage.value = config.bgImg; ui.loadingMessage("Loading configuration...");
if (elements.inputBackgroundOpacity) elements.inputBackgroundOpacity.value = config.bgOpacity; if (elements.inputApiKey) elements.inputApiKey.value = config.apiKey;
if (elements.activeChatContainer) { if (elements.inputWahaUrl) elements.inputWahaUrl.value = config.wahaUrl;
elements.activeChatContainer.style.setProperty('--background-image', `URL("${config.bgImg}")`); if (elements.inputSession) elements.inputSession.value = config.session;
elements.activeChatContainer.style.setProperty('--background-opacity', `${config.bgOpacity}`); if (elements.inputBackgroundImage) elements.inputBackgroundImage.value = config.bgImg;
} if (elements.inputBackgroundOpacity) elements.inputBackgroundOpacity.value = config.bgOpacity;
await updateOnlineStatus(); if (elements.activeChatContainer) {
setupEventListeners(); elements.activeChatContainer.style.setProperty('--background-image', `URL("${config.bgImg}")`);
try { elements.activeChatContainer.style.setProperty('--background-opacity', `${config.bgOpacity}`);
setupElementsData(); }
loadChats(); ui.loadingMessage("Getting server version...");
checkWahaStatus(); await updateOnlineStatus();
initWebSocket(); ui.loadingMessage("Setting up event listeners...");
} finally { setupEventListeners();
elements.chatsLoader.classList.add('hidden'); 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');
}
})
}); });
async function askForNotificationPermission() { async function askForNotificationPermission() {
notificationAuthorization = await Notification.requestPermission(); notificationAuthorization = await Notification.requestPermission();
} }
@ -131,7 +144,7 @@ function scrollToList(smooth = true) {
function setupEventListeners() { function setupEventListeners() {
window.addEventListener('resize', updateSidebarPosition); window.addEventListener('resize', updateSidebarPosition);
if (!window.location.hash) { if (!window.location.hash) {
window.location.hash = ''; window.location.hash = '';
} }
@ -190,16 +203,27 @@ function setupEventListeners() {
ui.renderChatList(filtered, activeChatState, selectChat); ui.renderChatList(filtered, activeChatState, selectChat);
}); });
ui.autoResizeTextArea(elements.messageInput);
elements.messageForm.addEventListener('submit', (e) => { elements.messageForm.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
sendMessage(); sendMessage();
}); });
elements.messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
elements.chatBottomBar.style.height = `${elements.chatInputPanel.offsetHeight}px`; elements.chatBottomBar.style.height = `${elements.chatInputPanel.offsetHeight}px`;
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
elements.chatBottomBar.style.height = elements.chatBottomBar.style.height =
`${elements.chatInputPanel.offsetHeight}px`; `${elements.chatInputPanel.offsetHeight}px`;
}); });
elements.mentioningIndicator.addEventListener('click', clearMentionCache);
observer.observe(elements.chatInputPanel); observer.observe(elements.chatInputPanel);
elements.chatBottomBarBtn.addEventListener('click', ui.toggleChatBottomBar); elements.chatBottomBarBtn.addEventListener('click', ui.toggleChatBottomBar);
@ -313,9 +337,9 @@ async function handleIncomingMessage(msg: Message) {
if (!msg.fromMe) { if (!msg.fromMe) {
messageTone.play(); messageTone.play();
} if (notificationAuthorization === "granted") {
if (notificationAuthorization === "granted") { new Notification("New message", { body: msg.body || msg.text });
new Notification("New message", { body: msg.body || msg.text }); }
} }
if (activeChatState && activeChatState.id === msgChatId) { if (activeChatState && activeChatState.id === msgChatId) {
@ -334,6 +358,12 @@ async function handleIncomingMessage(msg: Message) {
async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) { async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) {
if (isLoadingChat) return; if (isLoadingChat) return;
clearMentionCache();
const pageEl = document.getElementById("chat-page");
if (!pageEl) return;
mainView?.scrollTo(pageEl);
isLoadingChat = true; isLoadingChat = true;
setActiveChatState(chat); setActiveChatState(chat);
@ -408,7 +438,10 @@ async function sendMessage() {
fromMe: true, fromMe: true,
sender: 'me', sender: 'me',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
status: 'sending' status: 'sending',
replyTo: mentionCacheID ? {
body: mentionCacheText || "Mention (no text)"
} : null
} as any; } as any;
ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id); ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id);
@ -437,7 +470,7 @@ async function sendMessage() {
console.warn('readChat failed (non-fatal):', e.message); console.warn('readChat failed (non-fatal):', e.message);
} }
const responseData = await waha.sendTextMessage(activeChatState.id, text); const responseData = await waha.sendTextMessage(activeChatState.id, text, mentionCacheID);
const tempBubble = document.getElementById(tempMsg.id); const tempBubble = document.getElementById(tempMsg.id);
if (tempBubble) { if (tempBubble) {
@ -458,6 +491,8 @@ async function sendMessage() {
if (meta) meta.innerHTML = `<span style="color: #ef4444;">Failed to send</span>`; if (meta) meta.innerHTML = `<span style="color: #ef4444;">Failed to send</span>`;
} }
} }
clearMentionCache();
} }
async function sendFileMessage(file: File) { async function sendFileMessage(file: File) {

28
web/src/parser.ts Normal file
View file

@ -0,0 +1,28 @@
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, "\\$&");
}
}

View file

@ -1,7 +1,24 @@
import { Chat } from "./types"; import { Chat } from "./types";
import { elements } from "./ui";
export let activeChatState: Chat | null = null; export let activeChatState: Chat | null = null;
export let mentionCacheID: string | null = null;
export let mentionCacheText: string | null = null;
export function setActiveChatState(value: Chat | null) { export function setActiveChatState(value: Chat | null) {
activeChatState = value; 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');
} }

View file

@ -52,6 +52,7 @@ export interface Message {
chatId?: string; chatId?: string;
chat?: { id: string }; chat?: { id: string };
participant?: string; participant?: string;
replyTo?: Message;
} }
/** Temporary message used for optimistic UI updates */ /** Temporary message used for optimistic UI updates */

View file

@ -1,7 +1,8 @@
import { formatTime, normalizeId } from "./utils"; import { formatTime, normalizeId } from "./utils";
import { getChatPicture, getMessage, getMedia, getMoreChatMessages } from "./storage"; import { getChatPicture, getMessage, getMedia, getMoreChatMessages } from "./storage";
import type { Chat, Message } from "./types"; import type { Chat, Message } from "./types";
import { activeChatState } from "./states"; import { activeChatState, prepareMention } from "./states";
import { Parser } from "./parser";
export const elements = { export const elements = {
chatList: document.getElementById('chat-list') as HTMLUListElement, chatList: document.getElementById('chat-list') as HTMLUListElement,
@ -15,7 +16,7 @@ export const elements = {
activeChatAvatar: document.getElementById('active-chat-avatar') as HTMLDivElement, activeChatAvatar: document.getElementById('active-chat-avatar') as HTMLDivElement,
messagesContainer: document.getElementById('messages-container') as HTMLDivElement, messagesContainer: document.getElementById('messages-container') as HTMLDivElement,
messageForm: document.getElementById('message-form') as HTMLFormElement, messageForm: document.getElementById('message-form') as HTMLFormElement,
messageInput: document.getElementById('message-input') as HTMLInputElement, messageInput: document.getElementById('message-input') as HTMLTextAreaElement,
backToSidebarBtn: document.querySelector('.chat-header') as HTMLElement, backToSidebarBtn: document.querySelector('.chat-header') as HTMLElement,
sidebar: document.querySelector('.sidebar') as HTMLElement, sidebar: document.querySelector('.sidebar') as HTMLElement,
appContainer: document.querySelector('.app-container') as HTMLElement, appContainer: document.querySelector('.app-container') as HTMLElement,
@ -45,7 +46,10 @@ export const elements = {
inputUserStatus: document.getElementById('profile-page-status-input') as HTMLInputElement, inputUserStatus: document.getElementById('profile-page-status-input') as HTMLInputElement,
selectable: document.querySelectorAll('.selectable') as NodeListOf<HTMLElement>, selectable: document.querySelectorAll('.selectable') as NodeListOf<HTMLElement>,
nextPageButtons: document.querySelectorAll('.next-page-btn') as NodeListOf<HTMLElement>, nextPageButtons: document.querySelectorAll('.next-page-btn') as NodeListOf<HTMLElement>,
scrollableViews: document.querySelectorAll('._scrollableView') 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
}; };
export const views = new Map<HTMLElement, ScrollableView>; export const views = new Map<HTMLElement, ScrollableView>;
@ -237,7 +241,10 @@ export const ui = {
msgs.shift(); msgs.shift();
msgs.forEach(async msg => { msgs.forEach(async msg => {
loadMoreButton.after(this.generateMessage(msg, userId, chatId)); const message = this.generateMessage(msg, userId, chatId);
if (message) {
loadMoreButton.after();
}
}); });
}, },
@ -245,7 +252,10 @@ export const ui = {
* Append a single message (used for optimistic updates immediately upon sending) * Append a single message (used for optimistic updates immediately upon sending)
*/ */
appendSingleMessage(msg: Message, userID: string, chatId: string, isLocal: boolean = false) { appendSingleMessage(msg: Message, userID: string, chatId: string, isLocal: boolean = false) {
elements.messagesContainer.appendChild(this.generateMessage(msg, userID, chatId, isLocal)) const message = this.generateMessage(msg, userID, chatId, isLocal);
if (message) {
elements.messagesContainer.appendChild(message)
}
}, },
generateTempMessageLink(msg: Message) { generateTempMessageLink(msg: Message) {
@ -269,6 +279,7 @@ export const ui = {
}, },
generateMessage(msg: Message, userID: string, chatId: string, isLocal: boolean = false) { 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'; const isOutgoing = msg.fromMe || msg.sender === 'me';
function getPrevMessageElem() { function getPrevMessageElem() {
@ -280,6 +291,7 @@ export const ui = {
const groupDiv = document.createElement('div'); const groupDiv = document.createElement('div');
groupDiv.className = `message-group selectable ${isOutgoing ? 'outgoing' : 'incoming'}`; groupDiv.className = `message-group selectable ${isOutgoing ? 'outgoing' : 'incoming'}`;
groupDiv.id = normalizeId(msg._serialized ? (msg._serialized as any) : msg.id) || "msg-id"; 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.timestamp = msg.timestamp?.toString();
groupDiv.dataset.from = msg.participant || (msg.from as string); groupDiv.dataset.from = msg.participant || (msg.from as string);
@ -319,8 +331,42 @@ export const ui = {
const contentEl = document.createElement('div'); const contentEl = document.createElement('div');
contentEl.classList.add('message-content'); 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 textEl = document.createElement('div');
textEl.innerHTML = msg.body || msg.text || ""; 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;
contentEl.appendChild(textEl); contentEl.appendChild(textEl);
bubble.appendChild(contentEl); bubble.appendChild(contentEl);
@ -354,6 +400,7 @@ export const ui = {
(e.target as HTMLAnchorElement).href = objectUrl; (e.target as HTMLAnchorElement).href = objectUrl;
if (media.blob.type.startsWith('image/')) { if (media.blob.type.startsWith('image/')) {
groupDiv.classList.add('image');
a.textContent = ""; a.textContent = "";
const img = document.createElement('img'); const img = document.createElement('img');
img.classList.add('message-image-attachement'); img.classList.add('message-image-attachement');
@ -370,7 +417,7 @@ export const ui = {
a.addEventListener('click', clickListener); a.addEventListener('click', clickListener);
} }
this.ensureScroll(elements.messagesContainer, () => { this.ensureScroll(elements.messagesContainer, () => {
contentEl.appendChild(a); contentEl.appendChild(a);
}); });
@ -380,7 +427,6 @@ export const ui = {
} }
} }
const meta = document.createElement('div'); const meta = document.createElement('div');
meta.className = 'message-meta'; meta.className = 'message-meta';
meta.innerHTML = `<span>${timeStr}</span>${statusCheck}`; meta.innerHTML = `<span>${timeStr}</span>${statusCheck}`;
@ -408,6 +454,10 @@ export const ui = {
groupDiv.appendChild(indicator); groupDiv.appendChild(indicator);
} else groupDiv.classList.add('same-sender'); } else groupDiv.classList.add('same-sender');
} }
bubble.addEventListener('dblclick', () => {
prepareMention(msg.id.toString(), parsed);
});
groupDiv.appendChild(bubble); groupDiv.appendChild(bubble);
return groupDiv; return groupDiv;
@ -447,6 +497,46 @@ export const ui = {
const message = document.getElementById(msgId); const message = document.getElementById(msgId);
if (message) message.remove(); 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 { export class ScrollableView {

View file

@ -1,10 +1,5 @@
import type { Message, MessageWithTime } from "./types"; 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 { export function formatTime(dateVal: string | number | Date): string {
if (!dateVal) return ''; if (!dateVal) return '';
let date: Date; let date: Date;
@ -26,12 +21,6 @@ export function formatTime(dateVal: string | number | Date): string {
return `${hours}:${minutesStr} ${ampm}`; 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[] { export function compensateMessageOrdering(messages: Message[]): Message[] {
if (!Array.isArray(messages)) return []; if (!Array.isArray(messages)) return [];
@ -48,32 +37,6 @@ export function compensateMessageOrdering(messages: Message[]): Message[] {
msgs.sort((a, b) => a._time - b._time); 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 // clean temp property
return msgs.map(({ _time, ...m }) => m); return msgs.map(({ _time, ...m }) => m);
} }
@ -99,11 +62,6 @@ 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 { export function normalizeId(raw: string | { _serialized?: string; user?: string } | null | undefined): string | null {
if (!raw) return null; if (!raw) return null;
if (typeof raw === 'object') { if (typeof raw === 'object') {

View file

@ -138,13 +138,14 @@ export const waha = {
}); });
}, },
async sendTextMessage(chatId: string, text: string): Promise<Message> { async sendTextMessage(chatId: string, text: string, replyTo: string | null = null): Promise<Message> {
return request<Message>('/api/sendText', { return request<Message>('/api/sendText', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
chatId, chatId,
text, text,
session: config.session session: config.session,
replyTo: replyTo
}) })
}); });
}, },

View file

@ -1,35 +1,36 @@
@font-face { @font-face {
font-family: Selawik; font-family: Selawik;
src: url(./fonts/selawk.ttf); src: url(./fonts/selawk.ttf);
font-weight: normal; font-weight: normal;
} }
@font-face { @font-face {
font-family: Selawik; font-family: Selawik;
src: url(./fonts/selawkl.ttf); src: url(./fonts/selawkl.ttf);
font-weight: 200; font-weight: 200;
} }
@font-face { @font-face {
font-family: Selawik; font-family: Selawik;
src: url(./fonts/selawksl.ttf); src: url(./fonts/selawksl.ttf);
font-weight: 300; font-weight: 300;
} }
@font-face { @font-face {
font-family: Selawik; font-family: Selawik;
src: url(./fonts/selawkb.ttf); src: url(./fonts/selawkb.ttf);
font-weight: bold; font-weight: bold;
} }
@font-face { @font-face {
font-family: Selawik; font-family: Selawik;
src: url(./fonts/selawkb.ttf); src: url(./fonts/selawkb.ttf);
font-weight: 800; font-weight: 800;
} }
:root { :root {
--bg-main: black; --bg-main: black;
--bg-primary: black;
--bg-secondary: #242424; --bg-secondary: #242424;
--sidebar-bg: black; --sidebar-bg: black;
--chat-bg: black; --chat-bg: black;
@ -50,6 +51,9 @@
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.1); --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); --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); --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 { body.light {
@ -124,6 +128,10 @@ input:focus {
outline: none; outline: none;
} }
li {
margin-inline-start: 1em;
}
.app-container { .app-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -179,7 +187,8 @@ input:focus {
} }
.sidebar-header { .sidebar-header {
padding: 20px 20px 0px 20px; padding: var(--page-padding);
padding-bottom: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: self-start; align-items: self-start;
@ -295,7 +304,6 @@ input:focus {
#chat-search { #chat-search {
width: 100%; width: 100%;
padding: 12px;
border: none; border: none;
font-size: 0.9rem; font-size: 0.9rem;
transition: all 0.25s ease; transition: all 0.25s ease;
@ -316,7 +324,8 @@ input:focus {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
padding: 0 10px 10px 10px; padding: var(--container-padding);
padding-top: 0;
} }
.chat-list-header { .chat-list-header {
@ -355,21 +364,22 @@ input:focus {
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
border: 1px solid transparent; border: 1px solid transparent;
margin-left: 0px;
} }
.selected { .chat-item.selected {
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
transform: scale(0.98) skewX(10deg); transform: scale(0.98) skewX(10deg);
filter: blur(.1px); filter: blur(.1px);
opacity: .6; opacity: .6;
} }
.selectable:active { .chat-item.selectable:active {
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
transform: scale(0.98) skewX(10deg); transform: scale(0.98) skewX(10deg);
filter: blur(.1px); filter: blur(.1px);
opacity: .6; opacity: .6;
} }
.chat-item-info { .chat-item-info {
@ -461,7 +471,19 @@ input:focus {
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: .2em; padding: .6em .2em;
width: 60px;
align-items: center;
}
#desktop-aside * {
width: 38px;
height: 38px;
}
#desktop-aside img {
margin: .2rem;
margin-top: auto;
} }
/* Sidebar Footer */ /* Sidebar Footer */
@ -556,7 +578,7 @@ input:focus {
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
position: relative; position: relative;
overflow: hidden; overflow: clip;
z-index: 0; z-index: 0;
} }
@ -590,7 +612,7 @@ input:focus {
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
z-index: -1; z-index: -1;
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.75),
@ -636,7 +658,7 @@ input:focus {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 1rem; padding: 1rem;
padding-bottom: .6rem; padding-bottom: 0rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
user-select: text; user-select: text;
@ -666,6 +688,10 @@ input:focus {
transition: .1s; transition: .1s;
} }
.message-group.image {
max-width: 30%;
}
.message-group.incoming { .message-group.incoming {
align-self: flex-start; align-self: flex-start;
} }
@ -745,7 +771,6 @@ input:focus {
.message-image-attachement { .message-image-attachement {
max-width: 100%; max-width: 100%;
max-height: 10em;
} }
/* Chat Input Panel */ /* Chat Input Panel */
@ -783,7 +808,13 @@ input:focus {
background-color: transparent; background-color: transparent;
} }
.alternate-panel button:hover, .alternate-panel input:hover { .alternate-panel button:hover {
filter: invert(1);
background-color: var(--bg-primary);
}
.alternate-panel input:hover {
border-color: var(--bg-primary); border-color: var(--bg-primary);
background-color: var(--text-primary); background-color: var(--text-primary);
color: var(--bg-primary); color: var(--bg-primary);
@ -808,13 +839,18 @@ input:focus {
#message-input { #message-input {
flex: 1; flex: 1;
overflow-y: scroll;
padding: 12px 18px; padding: 12px 18px;
background-color: var(--text-primary); background-color: var(--text-primary);
border: medium solid var(--border-color); border: medium solid var(--border-color);
color: var(--bg-main); color: var(--bg-main);
font-size: 0.95rem; font-size: 0.95rem;
transition: all 0.2s ease; transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
min-width: 0; min-width: 0;
resize: none;
line-height: 1.3;
max-height: 6rem;
box-sizing: border-box;
} }
#message-input:focus { #message-input:focus {
@ -880,7 +916,7 @@ input:focus {
scroll-snap-align: start; scroll-snap-align: start;
scroll-snap-stop: always; scroll-snap-stop: always;
flex: 0 0 100%; flex: 0 0 100%;
} }
.extra-page-content { .extra-page-content {
@ -898,7 +934,7 @@ input:focus {
} }
#profile-page .content { #profile-page .content {
padding: 1.4rem; padding: var(--page-padding);
display: flex; display: flex;
gap: 1em; gap: 1em;
} }
@ -908,7 +944,6 @@ input:focus {
} }
.big-title { .big-title {
padding: 1.4rem;
font-size: 5rem; font-size: 5rem;
text-wrap-mode: nowrap; text-wrap-mode: nowrap;
overflow: hidden; overflow: hidden;
@ -956,6 +991,73 @@ input:focus {
font-size: 1rem; 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) { @media (max-width: 768px) {
.app-container { .app-container {
width: 100%; width: 100%;
@ -999,6 +1101,11 @@ input:focus {
scroll-snap-stop: always; scroll-snap-stop: always;
} }
#message-input {
padding: 8px 12px;
max-height: 10rem;
}
.extra-page { .extra-page {
display: flex; display: flex;
width: 100%; width: 100%;
@ -1023,6 +1130,10 @@ input:focus {
max-width: 90%; max-width: 90%;
} }
.message-group.image {
max-width: 60%;
}
.message-image-attachement { .message-image-attachement {
max-height: 20em; max-height: 20em;
} }
@ -1030,16 +1141,16 @@ input:focus {
#desktop-aside { #desktop-aside {
display: none; display: none;
} }
#profile-page .content { #profile-page .content {
flex-direction: column; flex-direction: column;
} }
#profile-page-picture { #profile-page-picture {
max-width: 50%; max-width: 50%;
margin: auto; margin: auto;
} }
#profile-page-user-number { #profile-page-user-number {
text-align: center; text-align: center;
} }
@ -1054,6 +1165,7 @@ input:focus {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
overflow-x: hidden;
} }
.modal-body h3 { .modal-body h3 {
@ -1065,10 +1177,10 @@ input:focus {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 1em 2em 0em 2em; padding: var(--page-padding) var(--page-padding) 0em var(--page-padding);
} }
.modal-header h2 { h2.modal-title {
font-size: xx-large; font-size: xx-large;
font-weight: 600; font-weight: 600;
} }
@ -1081,7 +1193,7 @@ input:focus {
overflow-y: auto; overflow-y: auto;
flex: 1; flex: 1;
overflow-x: hidden; overflow-x: hidden;
padding: 0 2rem 0 2rem; padding: 0 var(--page-padding) 0 var(--page-padding);
} }
.form-group { .form-group {
@ -1106,7 +1218,7 @@ input:focus {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 12px; gap: 12px;
padding: 1rem 2rem; padding: var(--page-padding-small) var(--page-padding);
} }
.btn { .btn {