diff --git a/web/index.html b/web/index.html
index ab8d213..0f827ba 100644
--- a/web/index.html
+++ b/web/index.html
@@ -13,7 +13,9 @@
-
-
Pandora
+
+
Pandora
+
@@ -56,7 +58,9 @@
Connecting
+
+
Connecting
+
diff --git a/web/src/app.ts b/web/src/app.ts
index cdf0242..8b7a9d5 100644
--- a/web/src/app.ts
+++ b/web/src/app.ts
@@ -7,7 +7,8 @@ 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, 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 longPressEvent = new CustomEvent("longpress");
@@ -18,29 +19,41 @@ if (!mainViewEl) throw console.error();
const mainView = views.get(mainViewEl);
document.addEventListener('DOMContentLoaded', async () => {
- 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');
- }
+ 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');
+ }
+ })
});
+
async function askForNotificationPermission() {
notificationAuthorization = await Notification.requestPermission();
}
@@ -131,7 +144,7 @@ function scrollToList(smooth = true) {
function setupEventListeners() {
window.addEventListener('resize', updateSidebarPosition);
-
+
if (!window.location.hash) {
window.location.hash = '';
}
@@ -190,16 +203,27 @@ 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);
@@ -313,9 +337,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) {
@@ -334,6 +358,12 @@ 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);
@@ -408,7 +438,10 @@ async function sendMessage() {
fromMe: true,
sender: 'me',
timestamp: new Date().toISOString(),
- status: 'sending'
+ status: 'sending',
+ replyTo: mentionCacheID ? {
+ body: mentionCacheText || "Mention (no text)"
+ } : null
} as any;
ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id);
@@ -437,7 +470,7 @@ async function sendMessage() {
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);
if (tempBubble) {
@@ -458,6 +491,8 @@ 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
new file mode 100644
index 0000000..6cc56ff
--- /dev/null
+++ b/web/src/parser.ts
@@ -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, "\\$&");
+ }
+}
\ No newline at end of file
diff --git a/web/src/states.ts b/web/src/states.ts
index f707e88..0865e98 100644
--- a/web/src/states.ts
+++ b/web/src/states.ts
@@ -1,7 +1,24 @@
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 d65614c..35acc77 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -52,6 +52,7 @@ 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 13f14bf..0d1b1dc 100644
--- a/web/src/ui.ts
+++ b/web/src/ui.ts
@@ -1,7 +1,8 @@
import { formatTime, normalizeId } from "./utils";
import { getChatPicture, getMessage, getMedia, getMoreChatMessages } from "./storage";
import type { Chat, Message } from "./types";
-import { activeChatState } from "./states";
+import { activeChatState, prepareMention } from "./states";
+import { Parser } from "./parser";
export const elements = {
chatList: document.getElementById('chat-list') as HTMLUListElement,
@@ -15,7 +16,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 HTMLInputElement,
+ messageInput: document.getElementById('message-input') as HTMLTextAreaElement,
backToSidebarBtn: document.querySelector('.chat-header') as HTMLElement,
sidebar: document.querySelector('.sidebar') 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,
selectable: document.querySelectorAll('.selectable') as NodeListOf,
nextPageButtons: document.querySelectorAll('.next-page-btn') as NodeListOf,
- scrollableViews: document.querySelectorAll('._scrollableView') 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
};
export const views = new Map;
@@ -237,7 +241,10 @@ export const ui = {
msgs.shift();
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)
*/
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) {
@@ -269,6 +279,7 @@ 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() {
@@ -280,6 +291,7 @@ 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);
@@ -319,8 +331,42 @@ 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'); - textEl.innerHTML = msg.body || msg.text || ""; + const parsed = new Parser(msg.body || msg.text || "") + .parse('_', '$1') + .parse('*', '$1') + .parse('~', '$1')
+ .parse('```', '$1')
+ .parse('`', '
") + .input; + + textEl.innerHTML = parsed; contentEl.appendChild(textEl); bubble.appendChild(contentEl); @@ -354,6 +400,7 @@ 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'); @@ -370,7 +417,7 @@ export const ui = { a.addEventListener('click', clickListener); } - + this.ensureScroll(elements.messagesContainer, () => { contentEl.appendChild(a); }); @@ -380,7 +427,6 @@ export const ui = { } } - const meta = document.createElement('div'); meta.className = 'message-meta'; meta.innerHTML = `${timeStr}${statusCheck}`; @@ -408,6 +454,10 @@ 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; @@ -447,6 +497,46 @@ 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 261f1bf..59e6799 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -1,10 +1,5 @@ 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; @@ -26,12 +21,6 @@ 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 []; @@ -48,32 +37,6 @@ 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); } @@ -99,11 +62,6 @@ 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 353c973..cb4638a 100644
--- a/web/src/waha.ts
+++ b/web/src/waha.ts
@@ -138,13 +138,14 @@ export const waha = {
});
},
- async sendTextMessage(chatId: string, text: string): Promise {
+ async sendTextMessage(chatId: string, text: string, replyTo: string | null = null): Promise {
return request('/api/sendText', {
method: 'POST',
body: JSON.stringify({
chatId,
text,
- session: config.session
+ session: config.session,
+ replyTo: replyTo
})
});
},
diff --git a/web/style.css b/web/style.css
index af6bb79..871f1ff 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1,35 +1,36 @@
@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;
@@ -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);
--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 {
@@ -124,6 +128,10 @@ input:focus {
outline: none;
}
+li {
+ margin-inline-start: 1em;
+}
+
.app-container {
display: flex;
flex-direction: row;
@@ -179,7 +187,8 @@ input:focus {
}
.sidebar-header {
- padding: 20px 20px 0px 20px;
+ padding: var(--page-padding);
+ padding-bottom: 0;
display: flex;
flex-direction: column;
align-items: self-start;
@@ -295,7 +304,6 @@ input:focus {
#chat-search {
width: 100%;
- padding: 12px;
border: none;
font-size: 0.9rem;
transition: all 0.25s ease;
@@ -316,7 +324,8 @@ input:focus {
display: flex;
flex-direction: column;
overflow-y: auto;
- padding: 0 10px 10px 10px;
+ padding: var(--container-padding);
+ padding-top: 0;
}
.chat-list-header {
@@ -355,21 +364,22 @@ input:focus {
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
+ margin-left: 0px;
}
-.selected {
- background: rgba(255, 255, 255, 0.04);
- transform: scale(0.98) skewX(10deg);
- filter: blur(.1px);
- opacity: .6;
+.chat-item.selected {
+ 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.selectable:active {
+ background: rgba(255, 255, 255, 0.04);
+ transform: scale(0.98) skewX(10deg);
+ filter: blur(.1px);
+ opacity: .6;
}
.chat-item-info {
@@ -461,7 +471,19 @@ input:focus {
border-right: 1px solid var(--border-color);
display: flex;
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 */
@@ -556,7 +578,7 @@ input:focus {
flex-direction: column;
height: 100%;
position: relative;
- overflow: hidden;
+ overflow: clip;
z-index: 0;
}
@@ -590,7 +612,7 @@ input:focus {
inset: 0;
pointer-events: none;
z-index: -1;
-
+
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.75),
@@ -636,7 +658,7 @@ input:focus {
flex: 1;
overflow-y: auto;
padding: 1rem;
- padding-bottom: .6rem;
+ padding-bottom: 0rem;
display: flex;
flex-direction: column;
user-select: text;
@@ -666,6 +688,10 @@ input:focus {
transition: .1s;
}
+.message-group.image {
+ max-width: 30%;
+}
+
.message-group.incoming {
align-self: flex-start;
}
@@ -745,7 +771,6 @@ input:focus {
.message-image-attachement {
max-width: 100%;
- max-height: 10em;
}
/* Chat Input Panel */
@@ -783,7 +808,13 @@ input:focus {
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);
background-color: var(--text-primary);
color: var(--bg-primary);
@@ -808,13 +839,18 @@ input:focus {
#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: all 0.2s ease;
+ transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
min-width: 0;
+ resize: none;
+ line-height: 1.3;
+ max-height: 6rem;
+ box-sizing: border-box;
}
#message-input:focus {
@@ -880,7 +916,7 @@ input:focus {
scroll-snap-align: start;
scroll-snap-stop: always;
flex: 0 0 100%;
-
+
}
.extra-page-content {
@@ -898,7 +934,7 @@ input:focus {
}
#profile-page .content {
- padding: 1.4rem;
+ padding: var(--page-padding);
display: flex;
gap: 1em;
}
@@ -908,7 +944,6 @@ input:focus {
}
.big-title {
- padding: 1.4rem;
font-size: 5rem;
text-wrap-mode: nowrap;
overflow: hidden;
@@ -956,6 +991,73 @@ input:focus {
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%;
@@ -999,6 +1101,11 @@ input:focus {
scroll-snap-stop: always;
}
+ #message-input {
+ padding: 8px 12px;
+ max-height: 10rem;
+ }
+
.extra-page {
display: flex;
width: 100%;
@@ -1023,6 +1130,10 @@ input:focus {
max-width: 90%;
}
+ .message-group.image {
+ max-width: 60%;
+ }
+
.message-image-attachement {
max-height: 20em;
}
@@ -1030,16 +1141,16 @@ input:focus {
#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;
}
@@ -1054,6 +1165,7 @@ input:focus {
display: flex;
flex-direction: column;
height: 100%;
+ overflow-x: hidden;
}
.modal-body h3 {
@@ -1065,10 +1177,10 @@ input:focus {
display: flex;
justify-content: space-between;
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-weight: 600;
}
@@ -1081,7 +1193,7 @@ input:focus {
overflow-y: auto;
flex: 1;
overflow-x: hidden;
- padding: 0 2rem 0 2rem;
+ padding: 0 var(--page-padding) 0 var(--page-padding);
}
.form-group {
@@ -1106,7 +1218,7 @@ input:focus {
display: flex;
justify-content: flex-end;
gap: 12px;
- padding: 1rem 2rem;
+ padding: var(--page-padding-small) var(--page-padding);
}
.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'); - textEl.innerHTML = msg.body || msg.text || ""; + const parsed = new Parser(msg.body || msg.text || "") + .parse('_', '$1') + .parse('*', '$1') + .parse('~', '
$1')
+ .replace("\n", "") + .input; + + textEl.innerHTML = parsed; contentEl.appendChild(textEl); bubble.appendChild(contentEl); @@ -354,6 +400,7 @@ 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'); @@ -370,7 +417,7 @@ export const ui = { a.addEventListener('click', clickListener); } - + this.ensureScroll(elements.messagesContainer, () => { contentEl.appendChild(a); }); @@ -380,7 +427,6 @@ export const ui = { } } - const meta = document.createElement('div'); meta.className = 'message-meta'; meta.innerHTML = `${timeStr}${statusCheck}`; @@ -408,6 +454,10 @@ 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; @@ -447,6 +497,46 @@ 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 261f1bf..59e6799 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -1,10 +1,5 @@ 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; @@ -26,12 +21,6 @@ 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 []; @@ -48,32 +37,6 @@ 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); } @@ -99,11 +62,6 @@ export function getBase64(file: File): Promise