diff --git a/web/index.html b/web/index.html
index 0f827ba..ab8d213 100644
--- a/web/index.html
+++ b/web/index.html
@@ -13,9 +13,7 @@
-
-
-
+ Pandora
-Pandora
@@ -58,9 +56,7 @@
-
+ Connecting
-Connecting
diff --git a/web/src/app.ts b/web/src/app.ts
index 8b7a9d5..cdf0242 100644
--- a/web/src/app.ts
+++ b/web/src/app.ts
@@ -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 = `Failed to send`;
}
}
-
- clearMentionCache();
}
async function sendFileMessage(file: File) {
diff --git a/web/src/parser.ts b/web/src/parser.ts
deleted file mode 100644
index 6cc56ff..0000000
--- a/web/src/parser.ts
+++ /dev/null
@@ -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, "\\$&");
- }
-}
\ No newline at end of file
diff --git a/web/src/states.ts b/web/src/states.ts
index 0865e98..f707e88 100644
--- a/web/src/states.ts
+++ b/web/src/states.ts
@@ -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');
}
\ No newline at end of file
diff --git a/web/src/types.ts b/web/src/types.ts
index 35acc77..d65614c 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -52,7 +52,6 @@ export interface Message {
chatId?: string;
chat?: { id: string };
participant?: string;
- replyTo?: Message;
}
/** Temporary message used for optimistic UI updates */
diff --git a/web/src/ui.ts b/web/src/ui.ts
index 0d1b1dc..13f14bf 100644
--- a/web/src/ui.ts
+++ b/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,
nextPageButtons: document.querySelectorAll('.next-page-btn') as NodeListOf,
- scrollableViews: document.querySelectorAll('._scrollableView') as NodeListOf,
- 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
};
export const views = new Map;
@@ -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('_', '$1')
- .parse('*', '$1')
- .parse('~', '$1')
- .parse('```', '$1')
- .parse('`', '
") - .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('_', '$1') - .parse('*', '$1') - .parse('~', '$1')
- .parse('```', '$1')
- .parse('`', '
") - .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 = `${timeStr}${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 { diff --git a/web/src/utils.ts b/web/src/utils.ts index 59e6799..261f1bf 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -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 {
});
}
+/**
+ * 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') {
diff --git a/web/src/waha.ts b/web/src/waha.ts
index cb4638a..353c973 100644
--- a/web/src/waha.ts
+++ b/web/src/waha.ts
@@ -138,14 +138,13 @@ export const waha = {
});
},
- async sendTextMessage(chatId: string, text: string, replyTo: string | null = null): Promise {
+ async sendTextMessage(chatId: string, text: string): Promise {
return request('/api/sendText', {
method: 'POST',
body: JSON.stringify({
chatId,
text,
- session: config.session,
- replyTo: replyTo
+ session: config.session
})
});
},
diff --git a/web/style.css b/web/style.css
index 871f1ff..af6bb79 100644
--- a/web/style.css
+++ b/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 {
$1')
- .replace("\n", "") - .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('_', '$1') - .parse('*', '$1') - .parse('~', '
$1')
- .replace("\n", "") - .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 = `${timeStr}${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 { diff --git a/web/src/utils.ts b/web/src/utils.ts index 59e6799..261f1bf 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -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