diff --git a/.gitignore b/.gitignore index 763301f..ffe0fcc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ dist/ -node_modules/ \ No newline at end of file +node_modules/ +.idea \ No newline at end of file diff --git a/package.json b/package.json index 4923aa4..f8b9b76 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "tsc && vite build && vite preview" }, "keywords": [], "author": "", diff --git a/web/app.html b/web/app.html new file mode 100644 index 0000000..2f7be0d --- /dev/null +++ b/web/app.html @@ -0,0 +1,175 @@ + + + + + + + Pandora + + + + + + + + + +
+
+ + + + + +
+
+ +
+ +
+
+
+
+

Select a contact to view the conversation or start a new chat.

+
+
+ + + +
+
+ +
+

Pandora User

+
+ +
+

Pandora User

+ +
+
+
+ +
+ +
+
+
+ + +
+ + + diff --git a/web/index.html b/web/index.html index e7363e7..ab8d213 100644 --- a/web/index.html +++ b/web/index.html @@ -5,43 +5,64 @@ Pandora - - - -
- - - - - -
- -
- -
-
-
-
-

Select a contact to view the conversation or start a new chat.

-
-
- - - -
-
- -
-

Pandora User

-
- -
-

Pandora User

- -
-
-
- -
-
- - + diff --git a/web/src/app.ts b/web/src/app.ts index 2a25772..cdf0242 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -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, 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 = `
@@ -311,18 +355,18 @@ async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) {
`; - + 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 = '
Error loading messages
'; } - + 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 = `${formatTime(new Date())}`; } - + 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); diff --git a/web/src/config.ts b/web/src/config.ts index f237bbe..cb53254 100644 --- a/web/src/config.ts +++ b/web/src/config.ts @@ -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', diff --git a/web/src/db.ts b/web/src/db.ts index 41cf38c..1c1f9fb 100644 --- a/web/src/db.ts +++ b/web/src/db.ts @@ -243,3 +243,7 @@ export async function loadMedia(reqId: string): Promise req.onerror = () => reject(req.error); }); } + +export function deleteDatabase() { + indexedDB.deleteDatabase(DB_NAME); +} \ No newline at end of file diff --git a/web/src/setup.ts b/web/src/setup.ts new file mode 100644 index 0000000..5be0007 --- /dev/null +++ b/web/src/setup.ts @@ -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(); + } +} \ No newline at end of file diff --git a/web/src/states.ts b/web/src/states.ts new file mode 100644 index 0000000..f707e88 --- /dev/null +++ b/web/src/states.ts @@ -0,0 +1,7 @@ +import { Chat } from "./types"; + +export let activeChatState: Chat | null = null; + +export function setActiveChatState(value: Chat | null) { + activeChatState = value; +} \ No newline at end of file diff --git a/web/src/storage.ts b/web/src/storage.ts index 011b44b..19977b1 100644 --- a/web/src/storage.ts +++ b/web/src/storage.ts @@ -164,4 +164,4 @@ export async function markRead(chatId: string): Promise { await upsertChats([chat]); } return chat; -} +} \ No newline at end of file diff --git a/web/src/ui.ts b/web/src/ui.ts index 8d524df..13f14bf 100644 --- a/web/src/ui.ts +++ b/web/src/ui.ts @@ -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, desktopSidebarButtons: document.querySelectorAll("#desktop-aside button") as NodeListOf, + desktopAside: document.getElementById('desktop-aside') as HTMLElement, contentUserName: document.querySelectorAll('[data-content="app-user"]') as NodeListOf, contentUserNumber: document.querySelectorAll('[data-content="app-user-number"]') as NodeListOf, resourceUserPic: document.querySelectorAll('[data-resource="app-user-image"]') as NodeListOf, valueUserStatus: document.querySelectorAll('[data-value="app-user-status"]') as NodeListOf, 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 }; -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; +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 = `
  • No chats found
  • `; 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 = `
    ${timeStr}
    - - ${chat.lastMessage || 'No messages yet'} + + ${chat.lastMessage || '...'} ${hasUnread ? `${chat.unreadCount}` : ''}
    `; - + 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 = '
    No messages. Say hello!
    '; 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 = ''; } } - + 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 = `${timeStr}${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 = ''; } - + 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 | 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)); +}) \ No newline at end of file diff --git a/web/src/waha.ts b/web/src/waha.ts index 95acd25..353c973 100644 --- a/web/src/waha.ts +++ b/web/src/waha.ts @@ -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 }; }); }, diff --git a/web/style.css b/web/style.css index 7122cda..387b52a 100644 --- a/web/style.css +++ b/web/style.css @@ -93,9 +93,6 @@ html, body { body { background-color: var(--bg-main); color: var(--text-primary); - background-image: - radial-gradient(at 0% 0%, rgba(99, 102, 241, 0.12) 0px, transparent 50%), - radial-gradient(at 100% 100%, rgba(168, 85, 247, 0.12) 0px, transparent 50%); } a { @@ -129,16 +126,44 @@ input:focus { .app-container { display: flex; + flex-direction: row; + width: 100%; + height: 100dvh; + overflow-x: auto; + overflow-y: hidden; + scroll-snap-type: x mandatory; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.main-holder { + display: flex; +} + +._scrollableView, ._scrollableContainer { + display: flex; + flex-direction: row; width: 100%; height: 100%; - overflow: hidden; - position: relative; + overflow-x: auto; + overflow-y: hidden; + scroll-snap-type: x mandatory; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.app-container::-webkit-scrollbar, ._scrollableContainer::-webkit-scrollbar, ._scrollableView::-webkit-scrollbar { + display: none; } .app-name { font-weight: bold; } +.dim { + opacity: .6; +} + /* Sidebar Styling */ .sidebar { width: 380px; @@ -237,10 +262,6 @@ input:focus { border-color: var(--border-hover); } -#chat-bottom-bar .icon-btn:hover::before { - color: var(--bg-main); -} - .icon-btn:active { transform: translateY(0); } @@ -444,6 +465,7 @@ input:focus { .api-status-badge { display: flex; align-items: center; + justify-content: center; gap: 8px; font-size: 0.75rem; color: var(--text-secondary); @@ -480,6 +502,7 @@ input:focus { display: flex; flex-direction: column; position: relative; + height: 100%; } /* Empty State */ @@ -847,8 +870,14 @@ input:focus { /* Extra Pages */ .extra-page { - display: none; + display: flex; + flex-direction: column; width: 100%; + height: 100%; + scroll-snap-align: start; + scroll-snap-stop: always; + flex: 0 0 100%; + } .extra-page-content { @@ -875,7 +904,7 @@ input:focus { flex: 1; } -#profile-page h2 { +.big-title { padding: 1.4rem; font-size: 5rem; text-wrap-mode: nowrap; @@ -935,13 +964,12 @@ input:focus { scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; - /* Hide scrollbars */ - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; + -ms-overflow-style: none; } .app-container::-webkit-scrollbar { - display: none; /* Chrome, Safari, Opera */ + display: none; } .sidebar { @@ -969,7 +997,7 @@ input:focus { } .extra-page { - display: block; + display: flex; width: 100%; flex: 0 0 100%; position: relative; @@ -1018,7 +1046,6 @@ input:focus { .modal-content { width: 100%; background: var(--bg-main); - padding: 24px; box-shadow: var(--shadow-lg); backdrop-filter: var(--glass-blur); display: flex; @@ -1035,7 +1062,7 @@ input:focus { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 20px; + padding: 1em 2em 0em 2em; } .modal-header h2 { @@ -1047,6 +1074,11 @@ input:focus { display: flex; flex-direction: column; gap: 16px; + min-height: 0; + overflow-y: auto; + flex: 1; + overflow-x: hidden; + padding: 0 2rem 0 2rem; } .form-group { @@ -1071,7 +1103,7 @@ input:focus { display: flex; justify-content: flex-end; gap: 12px; - margin-top: auto; + padding: 1rem 2rem; } .btn { @@ -1084,8 +1116,15 @@ input:focus { } .primary-btn { - background: var(--accent-gradient); - color: white; + background: transparent; + color: var(--text-primary); + border: medium solid var(--bg-secondary); +} + +.primary-btn:hover { + background: var(--text-primary); + color: var(--bg-main); + border-color: var(--text-primary); } .secondary-btn {