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.

This commit is contained in:
天クマ 2026-08-18 18:06:32 -03:00
commit f0066d6015
8 changed files with 264 additions and 49 deletions

View file

@ -106,7 +106,11 @@
</div>
<!-- Input Panel -->
<div id="mentioning-suggestion" class="collapsed">
<div id="mention-suggestions"></div>
</div>
<div id="mentioning-indicator" class="collapsed">
<span></span>
</div>
<footer id="chat-input-panel" class="chat-input-panel">
<div class="input-actions-left">
@ -123,6 +127,7 @@
<button id="markread-btn" class="icon-btn send-btn mif-done_all mif-2x" title="Mark read"></button>
<input id="attachment-input" style="display: none;" type="file">
<button id="attachment-btn" class="icon-btn send-btn mif-attachment mif-2x" title="Attach file"></button>
<button id="mention-btn" class="icon-btn send-btn mif-face mif-2x" title="Mention user"></button>
</footer>
</div>
</main>

View file

@ -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,6 +216,22 @@ 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 =
@ -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 => {
@ -360,6 +380,7 @@ 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 = `<span style="color: #ef4444;">Failed to send</span>`;
}
}
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();

View file

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

View file

@ -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<UserAboutResponse |
}
}
export async function getContact(id: string): Promise<Contact | undefined> {
if (online) {
return await waha.getContact(id);
} else {
return;
}
}
export function getChats(): Chat[] {
return chats;
}
@ -165,3 +173,27 @@ export async function markRead(chatId: string): Promise<Chat | undefined> {
}
return chat;
}
export async function getGroupUsers(groupId: string): Promise<GroupUser[] | undefined> {
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;
})
);
}

View file

@ -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
}

View file

@ -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<HTMLElement>,
desktopSidebarButtons: document.querySelectorAll("#desktop-aside button") as NodeListOf<HTMLButtonElement>,
@ -49,7 +50,9 @@ export const elements = {
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
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<HTMLElement, ScrollableView>;
@ -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);

View file

@ -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<T>(path: string, options: RequestInit = {}): Promise<T> {
const url = `${config.wahaUrl}${path}`;
@ -88,6 +88,10 @@ export const waha = {
});
},
async getContact(id: string) : Promise<Contact | undefined> {
return request<Contact>(`/api/${config.session}/contacts/${id}`);
},
async getChatMessages(chatId: string, beforeTimestamp?: any): Promise<Message[]> {
return request<Message[]>(`/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<Message> {
async sendTextMessage(chatId: string, text: string, mentions: string[] = [], replyTo: string | null = null): Promise<Message> {
return request<Message>('/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<Message>(endpoint, body);
return result;
},
async getGroupUsers(groupId: string): Promise<GroupUser[]> {
return request<GroupUser[]>(`/api/${config.session}/groups/${groupId}/participants`);
}
};

View file

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