Add loading screen; add support for showing mentions; add support for mentioning.
Some checks failed
Build Vite / build (24.x) (push) Has been cancelled

This commit is contained in:
天クマ 2026-08-17 22:07:30 -03:00
commit 75ef93ed80
8 changed files with 248 additions and 94 deletions

View file

@ -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, 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");
@ -20,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();
}
@ -133,7 +144,7 @@ function scrollToList(smooth = true) {
function setupEventListeners() {
window.addEventListener('resize', updateSidebarPosition);
if (!window.location.hash) {
window.location.hash = '';
}
@ -191,7 +202,7 @@ function setupEventListeners() {
);
ui.renderChatList(filtered, activeChatState, selectChat);
});
ui.autoResizeTextArea(elements.messageInput);
elements.messageForm.addEventListener('submit', (e) => {
@ -199,11 +210,20 @@ function setupEventListeners() {
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);
@ -338,11 +358,13 @@ 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);
@ -416,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);
@ -445,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) {
@ -466,6 +491,8 @@ async function sendMessage() {
if (meta) meta.innerHTML = `<span style="color: #ef4444;">Failed to send</span>`;
}
}
clearMentionCache();
}
async function sendFileMessage(file: File) {

View file

@ -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');
}

View file

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

View file

@ -1,7 +1,7 @@
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 = {
@ -46,7 +46,10 @@ export const elements = {
inputUserStatus: document.getElementById('profile-page-status-input') as HTMLInputElement,
selectable: document.querySelectorAll('.selectable') as NodeListOf<HTMLElement>,
nextPageButtons: document.querySelectorAll('.next-page-btn') as NodeListOf<HTMLElement>,
scrollableViews: document.querySelectorAll('._scrollableView') as NodeListOf<HTMLElement>
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>;
@ -276,7 +279,7 @@ export const ui = {
},
generateMessage(msg: Message, userID: string, chatId: string, isLocal: boolean = false) {
if (msg._data && msg._data.type == "gp2") return;
if (msg._data && msg._data.type == "gp2") return; // probably group description edit
const isOutgoing = msg.fromMe || msg.sender === 'me';
function getPrevMessageElem() {
@ -288,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);
@ -327,6 +331,31 @@ export const ui = {
const contentEl = document.createElement('div');
contentEl.classList.add('message-content');
if (msg.replyTo) {
const replyTo = msg.replyTo;
const replyIndicatorEl = document.createElement("div");
replyIndicatorEl.classList.add('reply-indicator');
replyIndicatorEl.textContent = new Parser(replyTo.body || replyTo.text || "")
.parse('_', '<i>$1</i>')
.parse('*', '<b>$1</b>')
.parse('~', '<s>$1</s>')
.parse('```', '<span style="font-family: monospace;">$1</span>')
.parse('`', '<code>$1</code>')
.replace("\n", "<br>")
.input;
replyIndicatorEl.addEventListener('click', () => {
const _msg = document.querySelector(`[id*="${replyTo.id}"]`) as HTMLElement;
if (_msg) {
_msg.scrollIntoView({ behavior: 'smooth', block: 'center' });
this.tempClass(_msg, "mentioned-highlight", 1000);
}
});
bubble.appendChild(replyIndicatorEl);
}
const textEl = document.createElement('div');
const parsed = new Parser(msg.body || msg.text || "")
.parse('_', '<i>$1</i>')
@ -388,7 +417,7 @@ export const ui = {
a.addEventListener('click', clickListener);
}
this.ensureScroll(elements.messagesContainer, () => {
contentEl.appendChild(a);
});
@ -398,7 +427,6 @@ export const ui = {
}
}
const meta = document.createElement('div');
meta.className = 'message-meta';
meta.innerHTML = `<span>${timeStr}</span>${statusCheck}`;
@ -426,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;
@ -486,6 +518,25 @@ export const ui = {
});
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 {

View file

@ -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 [];
@ -73,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 {
if (!raw) return null;
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', {
method: 'POST',
body: JSON.stringify({
chatId,
text,
session: config.session
session: config.session,
replyTo: replyTo
})
});
},