Add account setup workflow; move activeChatState to states.ts to avoid importing app.ts on ui.ts; add ScrollableView to ui.ts to contain and modularize scrollable views; make app layout use ScrollableView add option to delete data on settings page.
Some checks failed
Build Vite / build (24.x) (push) Has been cancelled

This commit is contained in:
天クマ 2026-08-16 17:33:59 -03:00
commit d4adfde73a
13 changed files with 652 additions and 301 deletions

View file

@ -1,28 +1,34 @@
import { config } from "./config";
import { waha } from "./waha";
import { ui, elements } from "./ui";
import { ui, elements, views } from "./ui";
import { websocket } from "./websocket";
import { compensateMessageOrdering, debounce, formatTime, normalizeId } from "./utils";
import { fetchChats, getAppUser, getChatMessages, getChatPicture, getChats, getUser, getUserAbout, markRead, sendStatus, updateOnlineStatus } from "./storage";
import { upsertMessages } from "./db";
import { deleteDatabase, upsertMessages } from "./db";
import { showNotification } from "./notification";
import type { Chat, Message, WebSocketEvent } from "./types";
import { activeChatState, setActiveChatState } from "./states";
let activeChatState: Chat | null = null;
const messageTone = new Audio("./message.ogg");
const longPressEvent = new CustomEvent("longpress");
export let isLoadingChat = false;
export let notificationAuthorization: NotificationPermission = "default";
const mainViewEl = document.getElementById("main-view");
if (!mainViewEl) throw console.error();
const mainView = views.get(mainViewEl);
document.addEventListener('DOMContentLoaded', async () => {
updateSidebarPosition();
askForNotificationPermission();
elements.inputApiKey.value = config.apiKey;
elements.inputWahaUrl.value = config.wahaUrl;
elements.inputSession.value = config.session;
elements.inputBackgroundImage.value = config.bgImg;
elements.inputBackgroundOpacity.value = config.bgOpacity;
elements.activeChatContainer.style.setProperty('--background-image', `URL("${config.bgImg}")`);
elements.activeChatContainer.style.setProperty('--background-opacity', `${config.bgOpacity}`);
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 {
@ -62,12 +68,21 @@ async function setupElementsData() {
}
}
function purgeDatabase(ask: boolean = true) {
if (ask) {
if (!(confirm("Are you sure you want to delete all cached messages?") && confirm("This cannot be undone. Proceed?"))) return;
}
localStorage.clear();
deleteDatabase();
location.reload();
}
function loadChats() {
elements.chatsLoader.classList.remove('hidden');
try {
fetchChats().then(async () => {
ui.renderChatList(getChats(), activeChatState, selectChat);
const hash = window.location.hash;
if (hash && hash.startsWith('#chat-')) {
const chatId = hash.replace('#chat-', '');
@ -115,10 +130,12 @@ function scrollToList(smooth = true) {
}
function setupEventListeners() {
window.addEventListener('resize', updateSidebarPosition);
if (!window.location.hash) {
window.location.hash = '';
}
window.addEventListener('hashchange', () => {
const hash = window.location.hash;
if (hash && hash.startsWith('#chat-')) {
@ -131,16 +148,16 @@ function setupEventListeners() {
closeActiveChat(true);
}
});
elements.appContainer.addEventListener('scroll', () => {
if (window.innerWidth > 768) return;
if (isScrollingProgrammatically) return;
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
const scrollLeft = elements.appContainer.scrollLeft;
const width = elements.appContainer.clientWidth;
if (scrollLeft < width * 0.2) {
// User swiped back to the list view
if (activeChatState) {
@ -149,14 +166,22 @@ function setupEventListeners() {
}
}, 100);
});
elements.appContainer.addEventListener('resize', () => {
if (window.innerWidth > 768) {
elements.desktopAside.after(elements.sidebar);
} else {
mainViewEl?.insertBefore(elements.sidebar, mainViewEl.firstChild);
}
})
document.addEventListener('keydown', (e) => {
if (e.code == "Escape") {
e.preventDefault();
closeActiveChat(false);
}
});
elements.chatSearch.addEventListener('input', (e: Event) => {
const query = (e.target as HTMLInputElement).value.toLowerCase();
const filtered = getChats().filter(chat =>
@ -164,24 +189,24 @@ function setupEventListeners() {
);
ui.renderChatList(filtered, activeChatState, selectChat);
});
elements.messageForm.addEventListener('submit', (e) => {
e.preventDefault();
sendMessage();
});
elements.chatBottomBar.style.height = `${elements.chatInputPanel.offsetHeight}px`;
const observer = new ResizeObserver(() => {
elements.chatBottomBar.style.height =
`${elements.chatInputPanel.offsetHeight}px`;
});
observer.observe(elements.chatInputPanel);
elements.chatBottomBarBtn.addEventListener('click', ui.toggleChatBottomBar);
elements.chatBottomBar.addEventListener('click', (e) => {
if (e.target == e.currentTarget) ui.toggleChatBottomBar();
});
elements.markreadBtn.addEventListener('click', async () => {
if (activeChatState) {
const result = await markRead(activeChatState.id);
@ -195,20 +220,25 @@ function setupEventListeners() {
const firstFile = this.files?.[0];
if (firstFile) sendFileMessage(firstFile);
})
elements.backToSidebarBtn.addEventListener('click', () => {
closeActiveChat(false);
});
elements.desktopSidebarButtons.forEach(sidebarBtn => {
sidebarBtn.addEventListener('click', () => {
const page = sidebarBtn.dataset.page;
if (page) ui.showExtraPage(page);
if (!page) return;
const pageEl = document.getElementById(page);
if (!pageEl) return;
if (page) mainView?.scrollTo(pageEl);
})
})
elements.saveSettingsBtn.addEventListener('click', saveSettings);
elements.purgeDatabaseButton.addEventListener('click', () => purgeDatabase(true));
elements.inputUserStatus.addEventListener('input', debounce(async function() {
const result = await sendStatus(elements.inputUserStatus.value);
if (result?.success) {
@ -218,32 +248,46 @@ function setupEventListeners() {
}
console.log(result);
}, 2000))
elements.selectable.forEach(e => {
let timerId: ReturnType<typeof setTimeout>, longPressed: boolean;
e.addEventListener('mousedown', () => {
longPressed = false;
timerId = setTimeout(() => {
longPressed = true;
e.dispatchEvent(longPressEvent);
}, 500); // 500ms for long press
})
e.addEventListener('click', (event) => {
if (longPressed) {
event.preventDefault();
clearTimeout(timerId);
}
})
e.addEventListener('mouseleave', () => {
clearTimeout(timerId);
})
})
}
function updateSidebarPosition() {
if (!mainViewEl || !elements.sidebar || !elements.desktopAside) return;
if (window.innerWidth > 768) {
if (elements.sidebar.parentElement !== elements.appContainer) {
elements.desktopAside.after(elements.sidebar);
}
} else {
if (elements.sidebar.parentElement !== mainViewEl) {
mainViewEl.insertBefore(elements.sidebar, mainViewEl.firstChild);
}
}
}
function initWebSocket() {
websocket.connect((data: WebSocketEvent) => {
const ev = data.event;
@ -257,23 +301,23 @@ function initWebSocket() {
async function handleIncomingMessage(msg: Message) {
if (!msg) return;
ui.updateChatInChatList(msg);
const rawChatId = msg.chatId || (typeof msg.from === 'string' ? msg.from : (msg.from as any)?._serialized) || (msg.chat && msg.chat.id);
const msgChatId = normalizeId(rawChatId);
if (!msgChatId) {
console.warn('[WS] Could not resolve chatId from payload:', msg);
return;
}
if (!msg.fromMe) {
messageTone.play();
}
if (notificationAuthorization === "granted") {
new Notification("New message", { body: msg.body || msg.text });
}
if (activeChatState && activeChatState.id === msgChatId) {
const msgId = normalizeId(msg.id as any) || (msg.id as string);
const exists = document.getElementById(msgId);
@ -290,16 +334,16 @@ async function handleIncomingMessage(msg: Message) {
async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) {
if (isLoadingChat) return;
isLoadingChat = true;
activeChatState = chat;
setActiveChatState(chat);
chat.unreadCount = 0;
ui.toggleChatState(true);
elements.activeChatName.textContent = chat.name.toUpperCase();
elements.activeChatAvatar.textContent = chat.name ? chat.name.substring(0, 1).toUpperCase() : '?';
elements.messagesContainer.innerHTML = `
<div class='loading-animation-wrapper'>
<div class="animation">
@ -311,18 +355,18 @@ async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) {
<div class="dot"></div>
</div>
</div>`;
elements.appContainer.classList.remove('no-active-chat');
if (window.innerWidth <= 768) {
scrollToChat(smoothScroll);
}
if (!isPopState && window.location.hash !== `#chat-${chat.id}`) {
window.location.hash = ``;
window.location.hash = `chat-${chat.id}`;
}
try {
const rawMessages = await getChatMessages(chat.id);
const processedMessages = compensateMessageOrdering(rawMessages);
@ -331,20 +375,20 @@ async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) {
console.error('Failed to load messages:', error);
elements.messagesContainer.innerHTML = '<div class="loading-chats">Error loading messages</div>';
}
isLoadingChat = false;
}
async function closeActiveChat(isPopState = false) {
activeChatState = null;
setActiveChatState(null);
if (window.innerWidth <= 768) {
scrollToList();
} else {
ui.toggleChatState(false);
}
if (!isPopState) {
if (window.location.hash.startsWith('#chat-')) {
history.back();
@ -355,9 +399,9 @@ async function closeActiveChat(isPopState = false) {
async function sendMessage() {
const text = elements.messageInput.value.trim();
if (!text || !activeChatState) return;
elements.messageInput.value = '';
const tempMsg = {
id: 'temp-' + Date.now(),
body: text,
@ -366,10 +410,10 @@ async function sendMessage() {
timestamp: new Date().toISOString(),
status: 'sending'
} as any;
ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id);
ui.scrollToBottom();
try {
try {
await waha.startTyping(activeChatState.id);
@ -378,13 +422,13 @@ async function sendMessage() {
} catch (e) {
console.warn('Presence start failed:', e);
}
try {
await waha.stopTyping(activeChatState.id);
} catch (e) {
console.warn('Presence stop failed:', e);
}
try {
if (!activeChatState.id.endsWith('@lid')) {
await waha.readChat(activeChatState.id);
@ -392,9 +436,9 @@ async function sendMessage() {
} catch (e: any) {
console.warn('readChat failed (non-fatal):', e.message);
}
const responseData = await waha.sendTextMessage(activeChatState.id, text);
const tempBubble = document.getElementById(tempMsg.id);
if (tempBubble) {
if (responseData && responseData.id) {
@ -403,7 +447,7 @@ async function sendMessage() {
const meta = tempBubble.querySelector('.message-meta');
if (meta) meta.innerHTML = `<span>${formatTime(new Date())}</span><span style="width:14px; height:14px;" class="mif-done">`;
}
activeChatState.lastMessage = text;
activeChatState.timestamp = new Date().toISOString();
} catch (error) {
@ -436,10 +480,10 @@ async function sendFileMessage(file: File) {
filename: file.name
}
} as any;
ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id, true);
ui.scrollToBottom();
const result = await waha.sendFileMessage(activeChatState.id, file);
ui.removeChatMessage(tempId);
ui.appendSingleMessage(result, activeChatState.name, (await getAppUser()).id);

View file

@ -8,8 +8,8 @@ export interface Config {
}
export const config: Config = {
wahaUrl: localStorage.getItem('waha_url') || 'http://inspiran.beetal-castor.ts.net:3100',
session: localStorage.getItem('waha_session') || 'session_01kxc62bk5fs8vh4v127k88a7j',
wahaUrl: localStorage.getItem('waha_url') || '',
session: localStorage.getItem('waha_session') || '',
apiKey: localStorage.getItem('waha_api_key') || '',
bgImg: localStorage.getItem('background_image') || '',
bgOpacity: localStorage.getItem('background_opacity') || '0.4',

View file

@ -243,3 +243,7 @@ export async function loadMedia(reqId: string): Promise<StoredMedia | undefined>
req.onerror = () => reject(req.error);
});
}
export function deleteDatabase() {
indexedDB.deleteDatabase(DB_NAME);
}

57
web/src/setup.ts Normal file
View file

@ -0,0 +1,57 @@
import { config } from "./config";
import { elements, ScrollableView, ui, views } from "./ui";
import { waha } from "./waha";
const page = views.get(elements.appContainer);
page?.scrollToIndex(0);
const statusConnection = document.getElementById("connecting-status");
const connectingPage = document.getElementById("connecting-page");
const loading = document.getElementById("connecting-animation");
const connectingNextBtn = document.getElementById("connecting-next-btn");
const settingsApiKey = document.getElementById("settings-api-key") as HTMLInputElement;
const settingsSession = document.getElementById("settings-session") as HTMLInputElement;
const settingsWahaURL = document.getElementById("settings-waha-url") as HTMLInputElement;
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
window.addEventListener('load', () => {
window.scrollTo(0, 0);
if (localStorage.getItem('setupComplete') == 'true') {
window.location.href = 'app.html';
}
});
if (connectingPage) {
connectingPage.addEventListener('intoView', () => {
testConnection();
})
}
connectingNextBtn?.addEventListener('click', () => {
localStorage.setItem('setupComplete', 'true');
window.location.href = "app.html";
})
async function testConnection() {
console.log(settingsWahaURL?.value)
config.save(settingsWahaURL?.value, settingsSession?.value, settingsApiKey?.value, "", "");
if (!statusConnection || !connectingNextBtn || !loading) return;
statusConnection.textContent = "Asking for server version...";
try {
const resp = await waha.getMyInfo();
if (resp.pushName != null) {
statusConnection.textContent = `Logged in as ${resp.pushName}`
connectingNextBtn.style.display = 'flex';
loading.style.display = 'none';
} else {
page?.previows();
}
} catch (e) {
console.log(e);
page?.previows();
}
}

7
web/src/states.ts Normal file
View file

@ -0,0 +1,7 @@
import { Chat } from "./types";
export let activeChatState: Chat | null = null;
export function setActiveChatState(value: Chat | null) {
activeChatState = value;
}

View file

@ -164,4 +164,4 @@ export async function markRead(chatId: string): Promise<Chat | undefined> {
await upsertChats([chat]);
}
return chat;
}
}

View file

@ -1,7 +1,7 @@
import { formatTime, normalizeId } from "./utils";
import { getChatPicture, getMessage, getMedia, getMoreChatMessages } from "./storage";
import { getCurrentChat } from "./app";
import type { Chat, Message } from "./types";
import { activeChatState } from "./states";
export const elements = {
chatList: document.getElementById('chat-list') as HTMLUListElement,
@ -22,6 +22,7 @@ export const elements = {
settingsModal: document.getElementById('settings-page') as HTMLElement,
settingsIconBtn: document.getElementById('settings-sidebar-btn') as HTMLButtonElement,
saveSettingsBtn: document.getElementById('save-settings') as HTMLButtonElement,
purgeDatabaseButton: document.getElementById('purge-database') as HTMLButtonElement,
inputWahaUrl: document.getElementById('settings-waha-url') as HTMLInputElement,
inputSession: document.getElementById('settings-session') as HTMLInputElement,
inputApiKey: document.getElementById('settings-api-key') as HTMLInputElement,
@ -36,25 +37,20 @@ export const elements = {
markreadBtn: document.getElementById('markread-btn') as HTMLButtonElement,
extraPages: document.querySelectorAll('.extra-page') as NodeListOf<HTMLElement>,
desktopSidebarButtons: document.querySelectorAll("#desktop-aside button") as NodeListOf<HTMLButtonElement>,
desktopAside: document.getElementById('desktop-aside') as HTMLElement,
contentUserName: document.querySelectorAll('[data-content="app-user"]') as NodeListOf<HTMLElement>,
contentUserNumber: document.querySelectorAll('[data-content="app-user-number"]') as NodeListOf<HTMLElement>,
resourceUserPic: document.querySelectorAll('[data-resource="app-user-image"]') as NodeListOf<HTMLImageElement>,
valueUserStatus: document.querySelectorAll('[data-value="app-user-status"]') as NodeListOf<HTMLInputElement>,
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>
};
export const ui = {
showExtraPage(pageId: string) {
elements.extraPages.forEach(page => {
if (page.id != pageId) {
page.style.display = "none";
} else {
page.style.display = "flex";
}
})
},
export const views = new Map<HTMLElement, ScrollableView>;
export const ui = {
/**
* Switch view state when a contact chat is opened or closed
*/
@ -67,14 +63,26 @@ export const ui = {
elements.activeChatContainer.classList.add('hidden');
}
},
/**
* Scroll message list automatically to bottom
*/
scrollToBottom() {
elements.messagesContainer.scrollTop = elements.messagesContainer.scrollHeight;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
elements.messagesContainer.scrollTop = elements.messagesContainer.scrollHeight;
});
});
},
ensureScroll(container: HTMLElement, after: Function) {
const scrolled = container.scrollTop === (container.scrollHeight - container.clientHeight);
after();
if (scrolled) {
container.scrollTop = container.scrollHeight;
}
},
/**
* Update connection status badge in sidebar footer
*/
@ -88,7 +96,7 @@ export const ui = {
elements.apiStatusIndicator.style.animation = 'none';
}
},
async renderChatList(chats: Chat[], activeChat: Chat | null, onChatSelect: (chat: Chat) => void) {
elements.chatList.innerHTML = '';
chats.sort((a, b) => {
@ -96,21 +104,22 @@ export const ui = {
const timeB = new Date(b.timestamp).getTime();
return timeB - timeA;
});
if (chats.length === 0) {
elements.chatList.innerHTML = `<li class="loading-chats">No chats found</li>`;
return;
}
for (const chat of chats) {
if (chat.timestamp == null) return;
const li = document.createElement('li');
li.className = `chat-item selectable ${activeChat && activeChat.id === chat.id ? 'active' : ''}`;
li.dataset.id = chat.id;
const initials = chat.name ? chat.name.substring(0, 1).toUpperCase() : '?';
const hasUnread = chat.unreadCount && chat.unreadCount > 0;
const timeStr = formatTime(chat.timestamp || new Date());
li.innerHTML = `
<div class="avatar">
<img
@ -125,17 +134,17 @@ export const ui = {
<span class="chat-item-time">${timeStr}</span>
</div>
<div class="chat-item-preview">
<span class="chat-item-msg" data-chatid="${chat.id}">
${chat.lastMessage || 'No messages yet'}
<span class="chat-item-msg${chat.lastMessage == null ? ' text-accent' : ''}" data-chatid="${chat.id}">
${chat.lastMessage || '...'}
</span>
${hasUnread ? `<span class="unread-badge">${chat.unreadCount}</span>` : ''}
</div>
</div>
`;
li.addEventListener('click', () => onChatSelect(chat));
elements.chatList.appendChild(li);
(async () => {
try {
const picture = await getChatPicture(chat.id);
@ -146,18 +155,16 @@ export const ui = {
})();
}
},
async updateChatInChatList(msg: Message) {
const chatNode = document.querySelector(`.chat-item[data-id="${msg.fromMe ? msg.to : msg.from}"]`) as HTMLElement;
if (chatNode) {
const messageItem = chatNode.querySelector('.chat-item-msg') as HTMLElement;
const time = chatNode.querySelector('.chat-item-time') as HTMLElement;
messageItem.innerText = msg.body || msg.text || 'Media message';
time.innerText = msg.timestamp ? formatTime(msg.timestamp) : formatTime(Date.now());
const activeChatState = getCurrentChat();
if (!msg.fromMe && (!activeChatState || activeChatState.id !== (msg.fromMe ? msg.to : msg.from))) {
const unreadBadge = chatNode.querySelector('.unread-badge') as HTMLElement;
if (unreadBadge) {
@ -172,10 +179,10 @@ export const ui = {
}
}
},
async updateChatInChatList2(chat: Chat) {
const chatNode = document.querySelector(`.chat-item[data-id="${chat.id}"]`) as HTMLElement;
if (chatNode) {
const messageItem = chatNode.querySelector('.chat-item-msg') as HTMLElement;
const time = chatNode.querySelector('.chat-item-time') as HTMLElement;
@ -190,18 +197,18 @@ export const ui = {
}
}
},
/**
* Render chat message log inside chat view container
*/
async renderMessages(messages: Message[], _activeChatName: string, userID: string, chatId: string) {
elements.messagesContainer.innerHTML = '';
if (messages.length === 0) {
elements.messagesContainer.innerHTML = '<div class="loading-chats">No messages. Say hello!</div>';
return;
}
const loadMore = document.createElement("button");
loadMore.classList.add("load-more-btn");
loadMore.innerText = "Load more";
@ -209,44 +216,44 @@ export const ui = {
this.loadMoreMessages(chatId, userID);
};
elements.messagesContainer.appendChild(loadMore);
for (const msg of messages) {
this.appendSingleMessage(msg, userID, chatId);
}
this.scrollToBottom();
},
async loadMoreMessages(chatId: string, userId: string) {
const oldest = document.querySelector('.message-group:first-of-type') as HTMLElement;
if (!oldest) return;
const oldestTimestamp = oldest.dataset.timestamp;
const oldestId = oldest.id;
const loadMoreButton = document.querySelector('.load-more-btn') as HTMLButtonElement;
const msgs = await getMoreChatMessages(chatId, oldestTimestamp, oldestId);
// JS version had msgs.shift(), probably to avoid duplication of the oldest message
msgs.shift();
msgs.forEach(async msg => {
loadMoreButton.after(this.generateMessage(msg, userId, chatId));
});
},
/**
* 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))
},
generateTempMessageLink(msg: Message) {
const a = document.createElement('a');
a.target = "_blank";
if (msg.media) {
a.href = msg.media.url;
if (msg._data?.mimetype?.startsWith('image/')) {
const img = document.createElement('img');
img.classList.add('message-image-attachement');
@ -257,28 +264,28 @@ export const ui = {
a.download = msg.media.filename || "file";
}
}
return a;
},
generateMessage(msg: Message, userID: string, chatId: string, isLocal: boolean = false) {
const isOutgoing = msg.fromMe || msg.sender === 'me';
function getPrevMessageElem() {
return elements.messagesContainer.lastElementChild as HTMLElement | null;
}
const prevMsgEl = getPrevMessageElem();
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.timestamp = msg.timestamp?.toString();
groupDiv.dataset.from = msg.participant || (msg.from as string);
const senderName = isOutgoing ? userID : (msg._data?.notifyName || (msg.from as string));
const timeStr = formatTime(msg.timestamp || new Date());
let statusCheck = '';
if (isOutgoing) {
if (msg.status === 'read') {
@ -291,42 +298,42 @@ export const ui = {
statusCheck = '<span class="mif-done" style="width:14px; height:14px;"></span>';
}
}
const bubble = document.createElement('div');
bubble.className = 'message-bubble';
let prevUid: string | undefined;
if (msg.participant) {
prevUid = msg.participant;
} else {
prevUid = msg.from as string;
}
if (!isOutgoing && (!prevMsgEl || prevUid !== prevMsgEl.dataset.from)) {
const senderEl = document.createElement('span');
senderEl.className = 'message-sender';
senderEl.textContent = senderName;
bubble.appendChild(senderEl);
}
const contentEl = document.createElement('div');
contentEl.classList.add('message-content');
const textEl = document.createElement('div');
textEl.innerHTML = msg.body || msg.text || "";
contentEl.appendChild(textEl);
bubble.appendChild(contentEl);
if (msg.hasMedia) {
let a: HTMLAnchorElement;
if (isLocal) {
a = this.generateTempMessageLink(msg);
} else {
a = document.createElement('a');
a.innerText = `[Request media]`;
a.target = "_blank";
const clickListener = async (e: MouseEvent) => {
a.removeEventListener('click', clickListener);
a.innerText = `[Downloading]`;
@ -338,44 +345,48 @@ export const ui = {
}
const url = new URL(mediaMsg.media.url);
const reqID = url.pathname.split('/').filter(Boolean).pop();
if (!reqID) return;
const media = await getMedia(reqID);
if (!media) return;
const objectUrl = URL.createObjectURL(media.blob);
(e.target as HTMLAnchorElement).href = objectUrl;
if (media.blob.type.startsWith('image/')) {
a.textContent = "";
const img = document.createElement('img');
img.classList.add('message-image-attachement');
img.src = objectUrl;
bubble.after(img);
const content = bubble.querySelector('.message-content');
if (content) content.remove();
this.ensureScroll(elements.messagesContainer, () => {
bubble.before(img);
})
// const content = bubble.querySelector('.message-content');
// if (content) content.remove(); // TODO make images attachment with text look cooler
} else {
(e.target as HTMLAnchorElement).textContent = media.filename || `Download ${mediaMsg.media.filename}`;
}
}
a.addEventListener('click', clickListener);
}
contentEl.appendChild(a);
this.ensureScroll(elements.messagesContainer, () => {
contentEl.appendChild(a);
});
if (!isLocal && msg._data?.mimetype?.startsWith('image/')) {
a.click();
}
}
const meta = document.createElement('div');
meta.className = 'message-meta';
meta.innerHTML = `<span>${timeStr}</span>${statusCheck}`;
bubble.appendChild(meta);
if (isOutgoing) {
if (prevMsgEl && prevMsgEl.classList.contains('outgoing')) {
groupDiv.classList.add('same-sender');
@ -397,18 +408,18 @@ export const ui = {
groupDiv.appendChild(indicator);
} else groupDiv.classList.add('same-sender');
}
groupDiv.appendChild(bubble);
return groupDiv;
},
updateMessage(originalMsgId: string, generatedMsg: HTMLElement) {
const originalMsg = document.querySelector(`#${originalMsgId}`);
if (originalMsg) {
originalMsg.replaceWith(generatedMsg)
}
},
updateMessageTick(id: string, status: string) {
let statusCheck;
if (status === 'read') {
@ -420,20 +431,123 @@ export const ui = {
} else {
statusCheck = '<span class="mif-done" style="width:14px; height:14px;"></span>';
}
const msgNode = document.getElementById(id);
if (msgNode) {
const meta = msgNode.querySelector('.message-meta');
if (meta) meta.outerHTML = statusCheck;
}
},
toggleChatBottomBar() {
elements.chatBottomBar.classList.toggle("collapsed");
},
removeChatMessage(msgId: string) {
const message = document.getElementById(msgId);
if (message) message.remove();
}
},
};
export class ScrollableView {
private observer: IntersectionObserver | null = null;
private activePage: HTMLElement | null = null;
container: HTMLElement;
isScrollingProgrammatically = false;
scrollTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(container: HTMLElement) {
this.container = container;
this.setupIntersectionObserver();
window.addEventListener('scroll', (e) => {
if (this.isScrollingProgrammatically) e.preventDefault();
});
container.querySelectorAll('.next-page-btn').forEach(nextBtn => {
nextBtn.addEventListener('click', () => {
this.next();
})
})
}
private setupIntersectionObserver() {
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && entry.target !== this.activePage) {
this.activePage = entry.target as HTMLElement;
entry.target.dispatchEvent(new CustomEvent('intoView', {
bubbles: true,
detail: {
page: entry.target,
index: this.getCurrentIndex()
}
}));
}
});
}, {
root: this.container,
threshold: 0.6
});
this.observePages();
}
observePages() {
if (!this.observer) return;
Array.from(this.container.children).forEach(child => {
this.observer?.observe(child);
});
}
get pages(): HTMLElement[] {
return Array.from(this.container.children) as HTMLElement[];
}
getCurrentIndex(): number {
const width = this.container.clientWidth;
if (width === 0) return 0;
return Math.round(this.container.scrollLeft / width);
}
getCurrentScreen(): HTMLElement | null {
const pages = this.pages;
return pages[this.getCurrentIndex()] || null;
}
scrollToIndex(index: number, smooth = true) {
const pages = this.pages;
if (index >= 0 && index < pages.length) {
this.scrollTo(pages[index], smooth);
}
}
next(smooth = true) {
this.scrollToIndex(this.getCurrentIndex() + 1, smooth);
}
previows(smooth = true) {
this.scrollToIndex(this.getCurrentIndex() - 1, smooth);
}
scrollTo(element: HTMLElement, smooth = true) {
this.isScrollingProgrammatically = true;
element.scrollIntoView({
behavior: smooth ? 'smooth' : 'auto',
inline: 'start',
block: 'nearest'
});
setTimeout(() => { this.isScrollingProgrammatically = false; }, smooth ? 400 : 50);
}
getCurrentExtraPage(): number {
const pages = Array.from(elements.extraPages);
const activeIndex = pages.findIndex(page => page.classList.contains("shown"));
return activeIndex !== -1 ? activeIndex : 0;
}
}
elements.scrollableViews.forEach(view => {
views.set(view, new ScrollableView(view));
})

View file

@ -79,11 +79,11 @@ export const waha = {
chatId = chatId._serialized || chatId.user || JSON.stringify(chatId);
}
return {
id: chatId || chat.chatId || chat.name,
name: chat.name || "Unknown Contact",
id: chatId || chat.chatId || null,
name: chat.name || null,
unreadCount: chat.unreadCount || 0,
lastMessage: chat.lastMessage?.body || chat.lastMessageText || "Click to open chat",
timestamp: chat.lastMessage?.timestamp || new Date()
lastMessage: chat.lastMessage?.body || chat.lastMessageText || null,
timestamp: chat.lastMessage?.timestamp || null
};
});
},