From f0066d601596d76d173a3f68607f12062aab5604 Mon Sep 17 00:00:00 2001 From: Adrian Victor Date: Tue, 18 Aug 2026 18:06:32 -0300 Subject: [PATCH] Add support for mentioning users; change loading screen loading animation to falling; restore chat close functionality on mobile; make message input resize on message send. --- web/app.html | 5 +++ web/src/app.ts | 106 ++++++++++++++++++++++++++++++++++----------- web/src/states.ts | 31 +++++++++++-- web/src/storage.ts | 34 ++++++++++++++- web/src/types.ts | 28 ++++++++++++ web/src/ui.ts | 8 +++- web/src/waha.ts | 15 +++++-- web/style.css | 86 +++++++++++++++++++++++++++++------- 8 files changed, 264 insertions(+), 49 deletions(-) diff --git a/web/app.html b/web/app.html index 41c918d..4de99f9 100644 --- a/web/app.html +++ b/web/app.html @@ -106,7 +106,11 @@ + diff --git a/web/src/app.ts b/web/src/app.ts index 715a536..6af85ab 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -3,11 +3,11 @@ import { waha } from "./waha"; 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 { fetchChats, getAppUser, getChatMessages, getChatPicture, getChats, getGroupUsers, getUser, getUserAbout, getUsersFromGroup, markRead, sendStatus, updateOnlineStatus } from "./storage"; import { deleteDatabase, upsertMessages } from "./db"; import { showNotification } from "./notification"; -import type { Chat, Message, WebSocketEvent } from "./types"; -import { activeChatState, clearMentionCache, mentionCacheID, mentionCacheText, setActiveChatState } from "./states"; +import type { Chat, GroupUser, Message, WebSocketEvent } from "./types"; +import { activeChatState, clearMentionCache, clearMentionedContacts, getMentionedIDs, mentionCacheID, mentionCacheText, mentionedContact, mentionedContacts, removeMentionedContact, setActiveChatState } from "./states"; if (localStorage.getItem('setupComplete') !== "true") window.location.href = "index.html"; const messageTone = new Audio("./message.ogg"); @@ -135,7 +135,7 @@ function scrollToChat(smooth = true) { function scrollToList(smooth = true) { isScrollingProgrammatically = true; - elements.appContainer.scrollTo({ + mainViewEl?.scrollTo({ left: 0, behavior: smooth ? 'smooth' : 'auto' }); @@ -172,7 +172,6 @@ function setupEventListeners() { const width = elements.appContainer.clientWidth; if (scrollLeft < width * 0.2) { - // User swiped back to the list view if (activeChatState) { closeActiveChat(false); } @@ -217,12 +216,28 @@ function setupEventListeners() { } }); + elements.messageInput.addEventListener('input', (e) => { + const inputEvent = e as InputEvent; + + mentionedContacts.forEach(c => { + if (!elements.messageInput.value.includes(`@${c.number}`)) { + removeMentionedContact(c); + } + }) + + if (inputEvent.data === "@") { + suggestMention(); + } else { + elements.mentioningSuggestion.classList.add('collapsed'); + } + }); + 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); @@ -236,14 +251,20 @@ function setupEventListeners() { const result = await markRead(activeChatState.id); if (result) ui.updateChatInChatList2(result); } - }) + }); + elements.attachmentBtn.addEventListener('click', () => { elements.attachmentInput.click(); - }) + }); elements.attachmentInput.addEventListener('change', function (this: HTMLInputElement) { const firstFile = this.files?.[0]; if (firstFile) sendFileMessage(firstFile); - }) + }); + + elements.mentionBtn.addEventListener('click', async () => { + suggestMention(); + ui.toggleChatBottomBar(); + }); elements.backToSidebarBtn.addEventListener('click', () => { closeActiveChat(false); @@ -270,7 +291,6 @@ function setupEventListeners() { } else { showNotification("Failed to update status...", "", 2000); } - console.log(result); }, 2000)) elements.selectable.forEach(e => { @@ -358,8 +378,9 @@ async function handleIncomingMessage(msg: Message) { async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) { if (isLoadingChat) return; - + clearMentionCache(); + clearMentionedContacts(); const pageEl = document.getElementById("chat-page"); if (!pageEl) return; @@ -429,8 +450,12 @@ async function closeActiveChat(isPopState = false) { async function sendMessage() { const text = elements.messageInput.value.trim(); if (!text || !activeChatState) return; + const _mentionCacheID = mentionCacheID; + const _mentionCacheText = mentionCacheText; + clearMentionCache(); elements.messageInput.value = ''; + elements.messageInput.dispatchEvent(new Event("input", { bubbles: true })); const tempMsg = { id: 'temp-' + Date.now(), @@ -439,8 +464,8 @@ async function sendMessage() { sender: 'me', timestamp: new Date().toISOString(), status: 'sending', - replyTo: mentionCacheID ? { - body: mentionCacheText || "Mention (no text)" + replyTo: _mentionCacheID ? { + body: _mentionCacheText || "Mention (no text)" } : null } as any; @@ -448,6 +473,14 @@ async function sendMessage() { ui.scrollToBottom(); try { + try { + if (!activeChatState.id.endsWith('@lid')) { + await waha.readChat(activeChatState.id); + } + } catch (e: any) { + console.warn('readChat failed (non-fatal):', e.message); + } + try { await waha.startTyping(activeChatState.id); const delay = Math.min(4000, Math.max(1000, text.length * 50)); @@ -462,15 +495,7 @@ async function sendMessage() { console.warn('Presence stop failed:', e); } - try { - if (!activeChatState.id.endsWith('@lid')) { - await waha.readChat(activeChatState.id); - } - } catch (e: any) { - 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, getMentionedIDs(), _mentionCacheID); const tempBubble = document.getElementById(tempMsg.id); if (tempBubble) { @@ -491,8 +516,6 @@ async function sendMessage() { if (meta) meta.innerHTML = `Failed to send`; } } - - clearMentionCache(); } async function sendFileMessage(file: File) { @@ -541,6 +564,39 @@ function saveSettings() { initWebSocket(); } +async function suggestMention() { + elements.mentionSuggestions.innerHTML = ""; + if (activeChatState) { + const gpUsrs: GroupUser[] | undefined = await getGroupUsers(activeChatState.id); + if (!gpUsrs) return; + const usrs = await getUsersFromGroup(gpUsrs); + if (!usrs) return; + + elements.mentioningSuggestion.classList.remove('collapsed'); + + usrs.forEach(u => { + if (!u) return; + const contact = document.createElement('div'); + contact.classList = "mention-suggestion"; + + const name = u.name ?? u.pushname; + if (!name) return; + + contact.innerText = name; + contact.addEventListener('click', () => { + mentionedContact(u); + elements.messageInput.value = elements.messageInput.value.substring(0, elements.messageInput.value.length - 1); + elements.messageInput.value += `@${u.number} `; + elements.messageInput.dispatchEvent(new Event("input", { bubbles: true })); + elements.mentioningSuggestion.classList.add("collapsed"); + elements.messageInput?.focus(); + }); + + elements.mentionSuggestions.appendChild(contact); + }) + } +} + async function checkWahaStatus() { try { const data = await waha.getVersion(); @@ -552,4 +608,4 @@ async function checkWahaStatus() { export function getCurrentChat() { return activeChatState; -} +} \ No newline at end of file diff --git a/web/src/states.ts b/web/src/states.ts index 0865e98..39bfe03 100644 --- a/web/src/states.ts +++ b/web/src/states.ts @@ -1,9 +1,10 @@ -import { Chat } from "./types"; +import { Chat, Contact } 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 let mentionedContacts: Contact[] = []; export function setActiveChatState(value: Chat | null) { activeChatState = value; @@ -12,13 +13,37 @@ export function setActiveChatState(value: Chat | null) { export function prepareMention(id: string, text: string) { mentionCacheID = id; mentionCacheText = text; - elements.mentioningIndicator.innerText = `Mentioning "${text}".`; + const span = elements.mentioningIndicator.querySelector('span'); + if (span) span.innerText = `Mentioning "${text}".`; elements.mentioningIndicator.classList.remove('collapsed'); } export function clearMentionCache() { mentionCacheID = null; mentionCacheText = null; - elements.mentioningIndicator.innerText = ``; + const span = elements.mentioningIndicator.querySelector('span'); + if (span) span.innerText = ''; elements.mentioningIndicator.classList.add('collapsed'); +} + +export function mentionedContact(contact: Contact) { + mentionedContacts.push(contact); +} + +export function clearMentionedContacts() { + mentionedContacts = []; +} + +export function removeMentionedContact(contact: Contact) { + const index = mentionedContacts.indexOf(contact); + if (index === -1) return; + mentionedContacts.splice(index, 1); +} + +export function getMentionedIDs(): string[] { + const m = mentionedContacts.map(x => x.id); + const r: string[] = []; + m.forEach(_m => { if (_m) r.push(_m) }) + + return r; } \ No newline at end of file diff --git a/web/src/storage.ts b/web/src/storage.ts index 19977b1..bb026ec 100644 --- a/web/src/storage.ts +++ b/web/src/storage.ts @@ -1,6 +1,6 @@ import { loadChat, loadChatsSorted, loadLatestMessages, loadMedia, loadOlderMessages, upsertChats, upsertMedia, upsertMessages } from "./db"; import { waha } from "./waha"; -import type { Chat, Message, AppUser, ContactInfo, UserAboutResponse, ChatPictureResponse, StatusResponse, DownloadedMedia } from "./types"; +import type { Chat, Message, AppUser, ContactInfo, UserAboutResponse, ChatPictureResponse, StatusResponse, DownloadedMedia, GroupUser, Contact } from "./types"; let online = false; let chats: Chat[] = []; @@ -60,6 +60,14 @@ export async function getUserAbout(userId: string): Promise { + if (online) { + return await waha.getContact(id); + } else { + return; + } +} + export function getChats(): Chat[] { return chats; } @@ -164,4 +172,28 @@ export async function markRead(chatId: string): Promise { await upsertChats([chat]); } return chat; +} + +export async function getGroupUsers(groupId: string): Promise { + if (online) { + return await waha.getGroupUsers(groupId); + } + + return undefined; +} + +export async function getUsersFromGroup(users: (GroupUser | undefined)[]): Promise<(Contact | undefined)[]> { + if (!online) { + return users.map(() => undefined); + } + + return Promise.all( + users.map(async u => { + if (u?.id._serialized) { + return await getContact(u.id._serialized); + } + + return undefined; + }) + ); } \ No newline at end of file diff --git a/web/src/types.ts b/web/src/types.ts index 35acc77..515a266 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -123,3 +123,31 @@ export interface DownloadedMedia { export interface MessageWithTime extends Message { _time: number; } + +export interface GroupUser { + id: { + server: string | null, + user: string | null, + _serialized: string | null + }, + isAdmin: boolean | null, + isSuperAdmin: boolean | null +} + +export interface Contact { + id: string | null, + number: string | null, + isBusiness: boolean | null, + isEnterprise: boolean | null, + name: string | null, + pushname: string | null, + shortName: string | null, + statusMute: boolean | null, + type: string | null, + isMe: boolean | null, + isUser: boolean | null, + isGroup: boolean | null, + isWAContact: boolean | null, + isMyContact: boolean | null, + isBlocked: boolean | null +} \ No newline at end of file diff --git a/web/src/ui.ts b/web/src/ui.ts index 0d1b1dc..64a0eed 100644 --- a/web/src/ui.ts +++ b/web/src/ui.ts @@ -35,6 +35,7 @@ export const elements = { chatInputPanel: document.getElementById('chat-input-panel') as HTMLElement, attachmentInput: document.getElementById('attachment-input') as HTMLInputElement, attachmentBtn: document.getElementById('attachment-btn') as HTMLButtonElement, + mentionBtn: document.getElementById('mention-btn') as HTMLButtonElement, markreadBtn: document.getElementById('markread-btn') as HTMLButtonElement, extraPages: document.querySelectorAll('.extra-page') as NodeListOf, desktopSidebarButtons: document.querySelectorAll("#desktop-aside button") as NodeListOf, @@ -49,7 +50,9 @@ export const elements = { 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 + mentioningIndicator: document.querySelector('#mentioning-indicator') as HTMLElement, + mentioningSuggestion: document.querySelector('#mentioning-suggestion') as HTMLElement, + mentionSuggestions: document.querySelector('#mention-suggestions') as HTMLElement }; export const views = new Map; @@ -348,7 +351,7 @@ export const ui = { replyIndicatorEl.addEventListener('click', () => { const _msg = document.querySelector(`[id*="${replyTo.id}"]`) as HTMLElement; if (_msg) { - _msg.scrollIntoView({ behavior: 'smooth', block: 'center' }); + _msg.scrollIntoView({ behavior: 'smooth', block: 'start' }); this.tempClass(_msg, "mentioned-highlight", 1000); } }); @@ -457,6 +460,7 @@ export const ui = { bubble.addEventListener('dblclick', () => { prepareMention(msg.id.toString(), parsed); + elements.messageInput?.focus(); }); groupDiv.appendChild(bubble); diff --git a/web/src/waha.ts b/web/src/waha.ts index cb4638a..4242ba3 100644 --- a/web/src/waha.ts +++ b/web/src/waha.ts @@ -1,7 +1,7 @@ import { config } from "./config"; import { showNotification } from "./notification"; import { getBase64 } from "./utils"; -import type { Message, VersionResponse, AppUser, ContactInfo, UserAboutResponse, ChatPictureResponse, StatusResponse } from "./types"; +import type { Message, VersionResponse, AppUser, ContactInfo, UserAboutResponse, ChatPictureResponse, StatusResponse, GroupUser, Chat, Contact } from "./types"; async function request(path: string, options: RequestInit = {}): Promise { const url = `${config.wahaUrl}${path}`; @@ -88,6 +88,10 @@ export const waha = { }); }, + async getContact(id: string) : Promise { + return request(`/api/${config.session}/contacts/${id}`); + }, + async getChatMessages(chatId: string, beforeTimestamp?: any): Promise { return request(`/api/${config.session}/chats/${chatId}/messages?downloadMedia=false&limit=40${beforeTimestamp ? `&filter.timestamp.lte=${beforeTimestamp}` : "" }`); }, @@ -138,14 +142,15 @@ export const waha = { }); }, - async sendTextMessage(chatId: string, text: string, replyTo: string | null = null): Promise { + async sendTextMessage(chatId: string, text: string, mentions: string[] = [], replyTo: string | null = null): Promise { return request('/api/sendText', { method: 'POST', body: JSON.stringify({ chatId, text, session: config.session, - replyTo: replyTo + replyTo: replyTo, + mentions: mentions }) }); }, @@ -181,5 +186,9 @@ export const waha = { const result = await request(endpoint, body); return result; + }, + + async getGroupUsers(groupId: string): Promise { + return request(`/api/${config.session}/groups/${groupId}/participants`); } }; diff --git a/web/style.css b/web/style.css index 871f1ff..2154a40 100644 --- a/web/style.css +++ b/web/style.css @@ -66,10 +66,14 @@ body.light { --text-primary: black; --text-secondary: black; --text-muted: rgb(134, 134, 134); - --accent: black; --accent-gradient: white; --accent-hover: #4f46e5; --online-color: rgba(0, 0, 000, 0.6); + --accent: black; + --accent-gradient: white; + --accent-hover: #4f46e5; + --online-color: rgba(0, 0, 000, 0.6); --bubble-incoming: #01abaa; --bubble-outgoing: #016f6e; - --input-bg: white; --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --input-bg: white; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); --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); } @@ -884,21 +888,21 @@ li { } /* Scrollbars styling */ -::-webkit-scrollbar { +*::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { +*::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { +*::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.08); border-radius: 0px; } -::-webkit-scrollbar-thumb:hover { +*::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.16); } @@ -1029,35 +1033,73 @@ li { align-items: center; justify-content: center; flex-direction: column; - outline: thick solid var(--bg-secondary); gap: 1rem; } #loading-screen.collapsed { - transform: translateY(100%); + transform-origin: top center; + animation: fall 0.8s cubic-bezier(.4, 0, .8, .2) forwards; 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; + padding: 0.4em 0.8em; overflow: hidden; - opacity: 1; - margin-top: .8rem; } -#mentioning-indicator.collapsed { +#mentioning-indicator > span { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; +} + +#mentioning-indicator.collapsed, #mentioning-suggestion.collapsed { max-height: 0; padding-top: 0; padding-bottom: 0; margin-top: 0; - opacity: 0; + /* opacity: 0; */ pointer-events: none; } +#mentioning-suggestion { + transition: all .4s cubic-bezier(0.4, 0, 0.2, 1); + display: flex; + height: 4rem; + padding: 0.4rem 0.8rem; + background-color: var(--bg-secondary); +} + +#mention-suggestions { + display: flex; + gap: 1em; + align-items: center; + + flex: 1; + min-width: 0; + + overflow-x: auto; + overflow-y: hidden; + + justify-content: center; +} + +.mention-suggestion::before { + content: "[ "; +} + +.mention-suggestion::after { + content: " ]"; +} + +#mention-suggestions > * { + flex-shrink: 0; +} + @media (max-width: 768px) { .app-container { width: 100%; @@ -1252,3 +1294,17 @@ h2.modal-title { color: var(--bg-main); } +@keyframes fall { + 0% { + transform: perspective(600px) rotateX(0deg); + } + 70% { + transform: perspective(600px) rotateX(82deg); + } + 85% { + transform: perspective(600px) rotateX(94deg); + } + 100% { + transform: perspective(600px) rotateX(90deg); + } +} \ No newline at end of file