diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index c2d2ff8..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Build Vite - -on: - push: - branches: - - master - workflow_dispatch: - -permissions: - contents: write - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [24.x] - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - - - name: Install dependencies & build - run: | - npm ci - npm run build - - - name: Deploy - uses: peaceiris/actions-gh-pages@v4 - with: - publish_dir: ./dist - github_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index ffe0fcc..819f592 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,29 @@ -dist/ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +.DS_Store + +# Generated by package manager node_modules/ -.idea \ No newline at end of file + +# Generated by Cordova +/plugins/ +/platforms/ + +pandora.apks +/android/app/build/ +test-upload-key.jks diff --git a/README.md b/README.md index b7b3008..f310aab 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -PANDORA +PANDORA Pandora is an open-source web client for the WAHA API. @@ -13,7 +13,7 @@ Pandora is an open-source web client for the WAHA API. - [ ] Sending audio messages - [x] Sending attachments (sends image/video as such)4 - [x] Notification sound5 -- [x] Browser notification +- [ ] Browser notification - [x] Download attachments - [x] Automatically download images6 - [X] Local message storing diff --git a/web/fonts/selawk.ttf b/fonts/selawk.ttf similarity index 100% rename from web/fonts/selawk.ttf rename to fonts/selawk.ttf diff --git a/web/fonts/selawkb.ttf b/fonts/selawkb.ttf similarity index 100% rename from web/fonts/selawkb.ttf rename to fonts/selawkb.ttf diff --git a/web/fonts/selawkl.ttf b/fonts/selawkl.ttf similarity index 100% rename from web/fonts/selawkl.ttf rename to fonts/selawkl.ttf diff --git a/web/fonts/selawksb.ttf b/fonts/selawksb.ttf similarity index 100% rename from web/fonts/selawksb.ttf rename to fonts/selawksb.ttf diff --git a/web/fonts/selawksl.ttf b/fonts/selawksl.ttf similarity index 100% rename from web/fonts/selawksl.ttf rename to fonts/selawksl.ttf diff --git a/web/public/icon-192.png b/icon-192.png similarity index 100% rename from web/public/icon-192.png rename to icon-192.png diff --git a/web/public/icon-512.png b/icon-512.png similarity index 100% rename from web/public/icon-512.png rename to icon-512.png diff --git a/index.html b/index.html new file mode 100644 index 0000000..1f455f7 --- /dev/null +++ b/index.html @@ -0,0 +1,173 @@ + + + + + + + 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/js/app.js similarity index 63% rename from web/src/app.ts rename to js/app.js index cdf0242..1611b35 100644 --- a/web/src/app.ts +++ b/js/app.js @@ -1,38 +1,28 @@ -import { config } from "./config"; -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 { deleteDatabase, upsertMessages } from "./db"; -import { showNotification } from "./notification"; -import type { Chat, Message, WebSocketEvent } from "./types"; -import { activeChatState, setActiveChatState } from "./states"; +import { config } from "./config.js"; +import { waha } from "./waha.js"; +import { ui, elements } from "./ui.js"; +import { websocket } from "./websocket.js"; +import { compensateMessageOrdering, debounce, formatTime, normalizeId } from "./utils.js"; +import { fetchChats, getAppUser, getChatMessages, getChatPicture, getChats, getUser, getUserAbout, sendStatus, updateOnlineStatus } from "./storage.js"; +import { upsertMessages } from "./db.js"; +let activeChatState = 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(); - 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}`); - } + 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}`); await updateOnlineStatus(); setupEventListeners(); try { - setupElementsData(); + setupElementsData(); loadChats(); checkWahaStatus(); initWebSocket(); @@ -41,46 +31,29 @@ document.addEventListener('DOMContentLoaded', async () => { } }); -async function askForNotificationPermission() { - notificationAuthorization = await Notification.requestPermission(); -} - async function setupElementsData() { - try { - const usr = await getAppUser(); - const usrPic = (await getChatPicture(usr.id))?.url; - const usrInfo = await getUser(usr.id); - const usrAbout = (await getUserAbout(usr.id))?.about; - elements.contentUserName.forEach(e => { - e.innerHTML = usr.pushName || usr.name || ''; - }) - elements.contentUserNumber.forEach(async e => { - if (usrInfo) e.innerHTML = usrInfo.number; - }) - elements.resourceUserPic.forEach(async e => { - if (usrPic) e.src = usrPic; - }) - elements.valueUserStatus.forEach(async e => { - if (usrAbout) e.value = usrAbout.trim(); - }) - } catch (error: any) { - console.error(error.message); - } -} - -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(); + const usr = await getAppUser(); + const usrPic = (await getChatPicture(usr.id))?.url; + const usrInfo = await getUser(usr.id); + const usrAbout = (await getUserAbout(usr.id)).about; + elements.contentUserName.forEach(e => { + e.innerHTML = usr.pushName; + }) + elements.contentUserNumber.forEach(async e => { + e.innerHTML = usrInfo.number; + }) + elements.resourceUserPic.forEach(async e => { + e.src = usrPic; + }) + elements.valueUserStatus.forEach(async e => { + e.value = usrAbout.trim(); + }) } function loadChats() { elements.chatsLoader.classList.remove('hidden'); try { - fetchChats().then(async () => { + fetchChats().then(async () => { ui.renderChatList(getChats(), activeChatState, selectChat); const hash = window.location.hash; @@ -92,7 +65,7 @@ function loadChats() { } } }); - } catch (error: any) { + } catch (error) { console.error('Failed to load chats:', error); elements.chatList.innerHTML = `
  • @@ -109,7 +82,7 @@ function loadChats() { } let isScrollingProgrammatically = false; -let scrollTimeout: ReturnType | null = null; +let scrollTimeout = null; function scrollToChat(smooth = true) { isScrollingProgrammatically = true; @@ -130,8 +103,6 @@ function scrollToList(smooth = true) { } function setupEventListeners() { - window.addEventListener('resize', updateSidebarPosition); - if (!window.location.hash) { window.location.hash = ''; } @@ -148,12 +119,12 @@ function setupEventListeners() { closeActiveChat(true); } }); - + elements.appContainer.addEventListener('scroll', () => { if (window.innerWidth > 768) return; if (isScrollingProgrammatically) return; - if (scrollTimeout) clearTimeout(scrollTimeout); + clearTimeout(scrollTimeout); scrollTimeout = setTimeout(() => { const scrollLeft = elements.appContainer.scrollLeft; const width = elements.appContainer.clientWidth; @@ -166,15 +137,7 @@ 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(); @@ -182,9 +145,9 @@ function setupEventListeners() { } }); - elements.chatSearch.addEventListener('input', (e: Event) => { - const query = (e.target as HTMLInputElement).value.toLowerCase(); - const filtered = getChats().filter(chat => + elements.chatSearch.addEventListener('input', (e) => { + const query = e.target.value.toLowerCase(); + const filtered = getChats().filter(chat => chat.name.toLowerCase().includes(query) ); ui.renderChatList(filtered, activeChatState, selectChat); @@ -207,89 +170,59 @@ function setupEventListeners() { if (e.target == e.currentTarget) ui.toggleChatBottomBar(); }); - elements.markreadBtn.addEventListener('click', async () => { - if (activeChatState) { - 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.attachmentInput.addEventListener('change', function () { + const firstFile = this.files[0]; + sendFileMessage(firstFile); }) elements.backToSidebarBtn.addEventListener('click', () => { closeActiveChat(false); - }); - + }); + elements.desktopSidebarButtons.forEach(sidebarBtn => { sidebarBtn.addEventListener('click', () => { - const page = sidebarBtn.dataset.page; - if (!page) return; - const pageEl = document.getElementById(page); - if (!pageEl) return; - if (page) mainView?.scrollTo(pageEl); + ui.showExtraPage(sidebarBtn.dataset.page); }) }) - + 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) { - showNotification("Status updated successfully!", "", 2000); - } else { - showNotification("Failed to update status...", "", 2000); - } console.log(result); }, 2000)) - + elements.selectable.forEach(e => { - let timerId: ReturnType, longPressed: boolean; - + let timerId, longPressed; + e.addEventListener('mousedown', () => { longPressed = false; - + timerId = setTimeout(() => { longPressed = true; e.dispatchEvent(longPressEvent); - }, 500); // 500ms for long press + }) }) - - e.addEventListener('click', (event) => { + + e.addEventListener('click', () => { if (longPressed) { - event.preventDefault(); + e.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) => { + websocket.connect((data) => { + // console.log('[WS] Received event:', data.event, data); const ev = data.event; if (ev === 'message' || ev === 'message.any' || ev === 'message.ack') { handleIncomingMessage(data.payload); @@ -299,47 +232,46 @@ function initWebSocket() { } -async function handleIncomingMessage(msg: Message) { +async function handleIncomingMessage(msg) { 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 rawChatId = msg.chatId || msg.from || (msg.chat && msg.chat.id); const msgChatId = normalizeId(rawChatId); if (!msgChatId) { console.warn('[WS] Could not resolve chatId from payload:', msg); return; } + // console.log('[WS] Resolved msgChatId:', msgChatId, '| activeChatState:', activeChatState?.id); 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 msgId = normalizeId(msg.id) || msg.id; const exists = document.getElementById(msgId); if (!exists) { - const container = elements.messagesContainer; - const scrolled = container.scrollTop === (container.scrollHeight - container.clientHeight); + const scrolled = elements.messagesContainer.scrollTop == elements.messagesContainer.scrollTopMax; ui.appendSingleMessage({ ...msg, chatId: msgChatId }, activeChatState.name, (await getAppUser()).id); if (scrolled) { ui.scrollToBottom(); - } + } } } } -async function selectChat(chat: Chat, isPopState = false, smoothScroll = true) { +async function selectChat(chat, isPopState = false, smoothScroll = true) { if (isLoadingChat) return; isLoadingChat = true; - setActiveChatState(chat); + activeChatState = chat; chat.unreadCount = 0; + // ui.renderChatList(chatsState, activeChatState, selectChat); + ui.toggleChatState(true); elements.activeChatName.textContent = chat.name.toUpperCase(); elements.activeChatAvatar.textContent = chat.name ? chat.name.substring(0, 1).toUpperCase() : '?'; @@ -375,19 +307,19 @@ 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) { - setActiveChatState(null); + activeChatState = null; if (window.innerWidth <= 768) { scrollToList(); } else { ui.toggleChatState(false); } - + if (!isPopState) { if (window.location.hash.startsWith('#chat-')) { @@ -409,7 +341,7 @@ async function sendMessage() { sender: 'me', timestamp: new Date().toISOString(), status: 'sending' - } as any; + }; ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id); ui.scrollToBottom(); @@ -433,7 +365,7 @@ async function sendMessage() { if (!activeChatState.id.endsWith('@lid')) { await waha.readChat(activeChatState.id); } - } catch (e: any) { + } catch (e) { console.warn('readChat failed (non-fatal):', e.message); } @@ -442,26 +374,25 @@ async function sendMessage() { const tempBubble = document.getElementById(tempMsg.id); if (tempBubble) { if (responseData && responseData.id) { - tempBubble.id = normalizeId(responseData.id as any) || tempBubble.id; + tempBubble.id = normalizeId(responseData.id); } const meta = tempBubble.querySelector('.message-meta'); - if (meta) meta.innerHTML = `${formatTime(new Date())}`; + meta.innerHTML = `${formatTime(new Date())}`; } activeChatState.lastMessage = text; - activeChatState.timestamp = new Date().toISOString(); + activeChatState.timestamp = new Date(); } catch (error) { console.error('Failed to send message:', error); const tempBubble = document.getElementById(tempMsg.id); if (tempBubble) { const meta = tempBubble.querySelector('.message-meta'); - if (meta) meta.innerHTML = `Failed to send`; + meta.innerHTML = `Failed to send`; } } } -async function sendFileMessage(file: File) { - if (!activeChatState) return; +async function sendFileMessage(file) { try { const tempId = 'temp-' + Date.now(); const tempMsg = { @@ -479,15 +410,15 @@ async function sendFileMessage(file: File) { url: URL.createObjectURL(file), filename: file.name } - } as any; + }; - ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id, true); + ui.appendSingleMessage(tempMsg, activeChatState.name, (await getAppUser()).id, activeChatState.id, true); ui.scrollToBottom(); const result = await waha.sendFileMessage(activeChatState.id, file); ui.removeChatMessage(tempId); - ui.appendSingleMessage(result, activeChatState.name, (await getAppUser()).id); - } catch (error: any) { + ui.appendSingleMessage(result.id, activeChatState.name, (await getAppUser()).id, activeChatState.id); + } catch (error) { console.error(error.message); } } @@ -517,4 +448,4 @@ async function checkWahaStatus() { export function getCurrentChat() { return activeChatState; -} +} \ No newline at end of file diff --git a/web/src/config.ts b/js/config.js similarity index 60% rename from web/src/config.ts rename to js/config.js index cb53254..edb15f6 100644 --- a/web/src/config.ts +++ b/js/config.js @@ -1,20 +1,13 @@ -export interface Config { - wahaUrl: string; - session: string; - apiKey: string; - bgImg: string; - bgOpacity: string; - save(url: string, session: string, apiKey: string, bgImg: string, bgOpacity: string): void; -} +// Client Configuration State & Storage Manager -export const config: Config = { - wahaUrl: localStorage.getItem('waha_url') || '', - session: localStorage.getItem('waha_session') || '', +export const config = { + wahaUrl: localStorage.getItem('waha_url') || 'http://inspiran.beetal-castor.ts.net:3100', + session: localStorage.getItem('waha_session') || 'session_01kxc62bk5fs8vh4v127k88a7j', apiKey: localStorage.getItem('waha_api_key') || '', bgImg: localStorage.getItem('background_image') || '', bgOpacity: localStorage.getItem('background_opacity') || '0.4', - save(url: string, session: string, apiKey: string, bgImg: string, bgOpacity: string): void { + save(url, session, apiKey, bgImg, bgOpacity) { this.wahaUrl = url.trim().replace(/\/$/, ""); this.session = session.trim(); this.apiKey = apiKey.trim(); diff --git a/web/src/db.ts b/js/db.js similarity index 68% rename from web/src/db.ts rename to js/db.js index 1c1f9fb..f1eb2a2 100644 --- a/web/src/db.ts +++ b/js/db.js @@ -1,26 +1,25 @@ -import { normalizeId } from "./utils"; -import type { Chat, Message, StoredMedia } from "./types"; +import { normalizeId } from "./utils.js"; const DB_NAME = "pandora"; const DB_VERSION = 6; -let dbPromise: Promise | null = null; +let dbPromise = null; -function openDb(): Promise { +function openDb() { if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, DB_VERSION); - req.onupgradeneeded = () => { + req.onupgradeneeded = (e) => { const db = req.result; - const tx = req.transaction!; + const tx = req.transaction; if (!db.objectStoreNames.contains("chats")) { const store = db.createObjectStore("chats", { keyPath: "id" }); store.createIndex("timestamp", "timestamp", { unique: false }); } - let msgStore: IDBObjectStore; + let msgStore; if (!db.objectStoreNames.contains("messages")) { msgStore = db.createObjectStore("messages", { keyPath: "id" }); msgStore.createIndex("from", "from", { unique: false }); @@ -34,21 +33,22 @@ function openDb(): Promise { msgStore.createIndex("chatId_timestamp", ["chatId", "timestamp"], { unique: false }); } - // Migration logic - msgStore.openCursor().onsuccess = (event) => { - const cursor = (event.target as IDBRequest).result; - if (cursor) { - const m = cursor.value as Message; - const from = normalizeId(m.from); - const to = normalizeId(m.to); - const chatId = normalizeId(m.chatId) || (m.fromMe ? to : from); - if (!m.chatId && chatId) { - m.chatId = chatId; - cursor.update(m); + if (db.objectStoreNames.contains("messages")) { + msgStore.openCursor().onsuccess = (event) => { + const cursor = event.target.result; + if (cursor) { + const m = cursor.value; + const from = normalizeId(m.from); + const to = normalizeId(m.to); + const chatId = normalizeId(m.chatId) || (m.fromMe ? to : from); + if (!m.chatId && chatId) { + m.chatId = chatId; + cursor.update(m); + } + cursor.continue(); } - cursor.continue(); - } - }; + }; + } if (!db.objectStoreNames.contains("media")) { db.createObjectStore("media", { keyPath: "reqId" }); @@ -62,7 +62,7 @@ function openDb(): Promise { return dbPromise; } -export async function upsertChats(chats: Chat[]): Promise { +export async function upsertChats(chats) { const db = await openDb(); return new Promise((resolve, reject) => { @@ -84,7 +84,7 @@ export async function upsertChats(chats: Chat[]): Promise { }); } -export async function loadChatsSorted(): Promise { +export async function loadChatsSorted() { const db = await openDb(); return new Promise((resolve, reject) => { @@ -92,9 +92,9 @@ export async function loadChatsSorted(): Promise { const store = tx.objectStore("chats"); const idx = store.index("timestamp"); - const result: Chat[] = []; + const result = []; idx.openCursor(null, "prev").onsuccess = (e) => { - const cursor = (e.target as IDBRequest).result; + const cursor = e.target.result; if (cursor) { result.push(cursor.value); cursor.continue(); @@ -107,20 +107,7 @@ export async function loadChatsSorted(): Promise { }); } -export async function loadChat(chatId: string): Promise { - const db = await openDb(); - - return new Promise((resolve, reject) => { - const tx = db.transaction("chats", "readonly"); - const store = tx.objectStore("chats"); - const req = store.get(chatId); - - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); -} - -function mapMessage(m: Message): any { +function mapMessage(m) { const from = normalizeId(m.from); const to = normalizeId(m.to); const chatId = normalizeId(m.chatId) || (m.fromMe ? to : from); @@ -139,7 +126,7 @@ function mapMessage(m: Message): any { } } -export async function upsertMessages(messages: Message[]): Promise { +export async function upsertMessages(messages) { const db = await openDb(); return new Promise((resolve, reject) => { @@ -155,7 +142,7 @@ export async function upsertMessages(messages: Message[]): Promise { }); } -export async function loadLatestMessages(chatId: string, limit: number = 50): Promise { +export async function loadLatestMessages(chatId, limit = 50) { const db = await openDb(); return new Promise((resolve, reject) => { @@ -163,12 +150,12 @@ export async function loadLatestMessages(chatId: string, limit: number = 50): Pr const store = tx.objectStore("messages"); const idx = store.index("chatId_timestamp"); - const out: Message[] = []; + const out = []; const range = IDBKeyRange.bound([chatId, -Infinity], [chatId, Infinity]); idx.openCursor(range, "prev").onsuccess = (e) => { - const cursor = (e.target as IDBRequest).result; + const cursor = e.target.result; if (!cursor) return resolve(out); out.push(cursor.value); @@ -180,7 +167,7 @@ export async function loadLatestMessages(chatId: string, limit: number = 50): Pr }); } -export async function loadOlderMessages(chatId: string, oldestTimestamp: any, oldestId: string, limit: number = 50): Promise { +export async function loadOlderMessages(chatId, oldestTimestamp, oldestId, limit = 50) { const db = await openDb(); return new Promise((resolve, reject) => { @@ -188,7 +175,7 @@ export async function loadOlderMessages(chatId: string, oldestTimestamp: any, ol const store = tx.objectStore("messages"); const idx = store.index("cidTimestampId"); - const out: Message[] = []; + const out = []; const parsedTimestamp = isNaN(oldestTimestamp) ? oldestTimestamp : Number(oldestTimestamp); @@ -200,7 +187,7 @@ export async function loadOlderMessages(chatId: string, oldestTimestamp: any, ol ); idx.openCursor(range, "prev").onsuccess = (e) => { - const cursor = (e.target as IDBRequest).result; + const cursor = e.target.result; if (!cursor) return resolve(out); out.push(cursor.value); @@ -213,7 +200,7 @@ export async function loadOlderMessages(chatId: string, oldestTimestamp: any, ol }); } -export async function upsertMedia(reqId: string, blob: Blob, filename: string): Promise { +export async function upsertMedia(reqId, blob, filename) { const db = await openDb(); return new Promise((resolve, reject) => { @@ -231,7 +218,7 @@ export async function upsertMedia(reqId: string, blob: Blob, filename: string): }); } -export async function loadMedia(reqId: string): Promise { +export async function loadMedia(reqId) { const db = await openDb(); return new Promise((resolve, reject) => { @@ -242,8 +229,4 @@ export async function loadMedia(reqId: string): Promise req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); -} - -export function deleteDatabase() { - indexedDB.deleteDatabase(DB_NAME); } \ No newline at end of file diff --git a/web/src/storage.ts b/js/storage.js similarity index 51% rename from web/src/storage.ts rename to js/storage.js index 19977b1..238b4b9 100644 --- a/web/src/storage.ts +++ b/js/storage.js @@ -1,11 +1,10 @@ -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 { loadChatsSorted, loadLatestMessages, loadMedia, loadOlderMessages, upsertChats, upsertMedia, upsertMessages } from "./db.js"; +import { waha } from "./waha.js"; let online = false; -let chats: Chat[] = []; +let chats = []; -export async function updateOnlineStatus(): Promise { +export async function updateOnlineStatus() { try { await waha.getVersion(); online = true; @@ -15,17 +14,17 @@ export async function updateOnlineStatus(): Promise { } } -export async function fetchChats(): Promise { +export async function fetchChats() { if (online) { await getRemoteChats() } chats = await loadChatsSorted(); } -export async function getRemoteChats(): Promise { +export async function getRemoteChats() { const u = await waha.getChats(); - const mapped: Chat[] = u.map(chat => ({ + const mapped = u.map(chat => ({ id: chat.id, name: chat.name, lastMessage: chat.lastMessage, @@ -36,15 +35,15 @@ export async function getRemoteChats(): Promise { await upsertChats(mapped); } -export function getUsers(): Chat[] { +export function getUsers() { return chats.filter(c => c.id.endsWith("@c.us")); } -export function getGroups(): Chat[] { +export function getGroups() { return chats.filter(c => c.id.endsWith("@g.us")); } -export async function getUser(number: string): Promise { +export async function getUser(number) { if (online) { return await waha.getUser(number); } else { @@ -52,7 +51,7 @@ export async function getUser(number: string): Promise } } -export async function getUserAbout(userId: string): Promise { +export async function getUserAbout(userId) { if (online) { return await waha.getUserAbout(userId); } else { @@ -60,26 +59,25 @@ export async function getUserAbout(userId: string): Promise { +export async function getAppUser() { if (online) { const info = await waha.getMyInfo(); - localStorage.setItem('pandora-last-username', info.pushName || info.name || ''); + localStorage.setItem('pandora-last-username', info.name); localStorage.setItem('pandora-last-userid', info.id); return info; } else { return { pushName: localStorage.getItem('pandora-last-username') || 'Unknown', - name: localStorage.getItem('pandora-last-username') || 'Unknown', id: localStorage.getItem('pandora-last-userid') || 'Unknown' - } as AppUser; + } } } -export async function getMessage(chatId: string, msgId: string, downloadMedia: boolean): Promise { +export async function getMessage(chatId, msgId, downloadMedia) { if (online) { const newMessage = await waha.getSingleChatMessage(chatId, msgId, downloadMedia); upsertMessages([newMessage]); @@ -90,14 +88,14 @@ export async function getMessage(chatId: string, msgId: string, downloadMedia: b body: "You're offline", from: "system", timestamp: new Date().toISOString() - } as Message; + }; } } -export async function getMedia(reqId: string): Promise { +export async function getMedia(reqId) { const cached = await loadMedia(reqId); if (cached) { - return { blob: cached.blob, filename: cached.filename }; + return cached; } try { @@ -111,7 +109,7 @@ export async function getMedia(reqId: string): Promise { +export async function getChatMessages(chatId) { if (online) { const newMessages = await waha.getChatMessages(chatId); upsertMessages(newMessages); @@ -121,7 +119,7 @@ export async function getChatMessages(chatId: string): Promise { } } -export async function getMoreChatMessages(chatId: string, oldestTimestamp: any, oldestId: string): Promise { +export async function getMoreChatMessages(chatId, oldestTimestamp, oldestId, limit = 50) { if (online) { return waha.getChatMessages(chatId, oldestTimestamp); } else { @@ -129,7 +127,7 @@ export async function getMoreChatMessages(chatId: string, oldestTimestamp: any, } } -export async function getChatPicture(chatId: string): Promise { +export async function getChatPicture(chatId) { if (online) { return await waha.getChatPicture(chatId); } else { @@ -137,11 +135,11 @@ export async function getChatPicture(chatId: string): Promise { +export async function sendStatus(text) { if (online) { return await waha.setStatus(text); } else { @@ -149,19 +147,4 @@ export async function sendStatus(text: string): Promise { success: false } } -} - -export async function markRead(chatId: string): Promise { - if (online) { - await waha.readChat(chatId); - } - - const chat = await loadChat(chatId); - if (chat) { - chat.unreadCount = 0; - // Note: upsertMessages was called with [chat] in JS, but chat is a Chat object, not Message. - // Keeping JS behavior but chat is Chat type here. - await upsertChats([chat]); - } - return chat; } \ No newline at end of file diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..d2e1261 --- /dev/null +++ b/js/ui.js @@ -0,0 +1,396 @@ +import { formatTime, normalizeId } from "./utils.js"; +import { getChatPicture, getMessage, getMedia, getMoreChatMessages } from "./storage.js"; +import { getCurrentChat } from "./app.js"; + +export const elements = { + chatList: document.getElementById('chat-list'), + chatsLoader: document.getElementById('chats-loader'), + chatSearch: document.getElementById('chat-search'), + backendStatusText: document.getElementById('backend-status-text'), + apiStatusIndicator: document.querySelector('.pulse-dot'), + noChatState: document.getElementById('no-chat-state'), + activeChatContainer: document.getElementById('active-chat-container'), + activeChatName: document.getElementById('active-chat-name'), + activeChatAvatar: document.getElementById('active-chat-avatar'), + messagesContainer: document.getElementById('messages-container'), + messageForm: document.getElementById('message-form'), + messageInput: document.getElementById('message-input'), + backToSidebarBtn: document.querySelector('.chat-header'), + sidebar: document.querySelector('.sidebar'), + appContainer: document.querySelector('.app-container'), + settingsModal: document.getElementById('settings-page'), + settingsIconBtn: document.getElementById('settings-sidebar-btn'), + saveSettingsBtn: document.getElementById('save-settings'), + inputWahaUrl: document.getElementById('settings-waha-url'), + inputSession: document.getElementById('settings-session'), + inputApiKey: document.getElementById('settings-api-key'), + inputBackgroundImage: document.getElementById('settings-background-image'), + inputBackgroundOpacity: document.getElementById('settings-background-opacity'), + loggedUserName: document.getElementById('pandora-username'), + chatBottomBar: document.getElementById('chat-bottom-bar'), + chatBottomBarBtn: document.getElementById('chat-bottom-bar-btn'), + chatInputPanel: document.getElementById('chat-input-panel'), + attachmentInput: document.getElementById('attachment-input'), + attachmentBtn: document.getElementById('attachment-btn'), + extraPages: document.querySelectorAll('.extra-page'), + desktopSidebarButtons: document.querySelectorAll("#desktop-aside button"), + contentUserName: document.querySelectorAll('[data-content="app-user"]'), + contentUserNumber: document.querySelectorAll('[data-content="app-user-number"]'), + resourceUserPic: document.querySelectorAll('[data-resource="app-user-image"]'), + valueUserStatus: document.querySelectorAll('[data-value="app-user-status"]'), + inputUserStatus: document.getElementById('profile-page-status-input'), + selectable: document.querySelectorAll('.selectable'), +}; + +export const ui = { + showExtraPage(pageId) { + elements.extraPages.forEach(page => { + if (page.id != pageId) { + page.style.display = "none"; + } else { + page.style.display = "flex"; + } + }) + }, + + /** + * Switch view state when a contact chat is opened or closed + */ + toggleChatState(hasActive) { + if (hasActive) { + elements.noChatState.classList.add('hidden'); + elements.activeChatContainer.classList.remove('hidden'); + } else { + elements.noChatState.classList.remove('hidden'); + elements.activeChatContainer.classList.add('hidden'); + } + }, + + /** + * Scroll message list automatically to bottom + */ + scrollToBottom() { + elements.messagesContainer.scrollTop = elements.messagesContainer.scrollHeight; + }, + + /** + * Update connection status badge in sidebar footer + */ + updateConnectionStatus(isConnected, text) { + elements.backendStatusText.textContent = text; + if (isConnected) { + elements.apiStatusIndicator.style.backgroundColor = 'var(--online-color)'; + elements.apiStatusIndicator.style.animation = 'pulse 1.8s infinite'; + } else { + elements.apiStatusIndicator.style.backgroundColor = '#ef4444'; + elements.apiStatusIndicator.style.animation = 'none'; + } + }, + + async renderChatList(chats, activeChat, onChatSelect) { + elements.chatList.innerHTML = ''; + chats.sort((a, b) => b.timestamp - a.timestamp); + + if (chats.length === 0) { + elements.chatList.innerHTML = `
  • No chats found
  • `; + return; + } + + for (const chat of chats) { + 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 = ` +
    + ${initials} +
    +
    +
    + ${chat.name} + ${timeStr} +
    +
    + + ${chat.lastMessage || 'No messages yet'} + + ${hasUnread ? `${chat.unreadCount}` : ''} +
    +
    + `; + + li.addEventListener('click', () => onChatSelect(chat)); + elements.chatList.appendChild(li); + + (async () => { + try { + const picture = await getChatPicture(chat.id); + const img = li.querySelector(`img[data-chat-avatar="${chat.id}"]`); + if (img) img.src = picture.url ? picture.url : ''; + } catch (e) { + } + })(); + } + }, + + async updateChatInChatList(msg) { + const chat = document.querySelector(`.chat-item[data-id="${msg.fromMe ? msg.to : msg.from}"]`); + + if (chat) { + const messageItem = chat.querySelector('.chat-item-msg'); + const time = chat.querySelector('.chat-item-time') + messageItem.innerText = msg.body || msg.text || 'Media message'; + time.innerText = msg.timestamp ? formatTime(msg.timestamp) : Date.now(); + + const activeChatState = getCurrentChat(); + + if (!msg.fromMe && (!activeChatState || activeChatState.id !== (msg.fromMe ? msg.to : msg.from))) { + const unreadBadge = chat.querySelector('.unread-badge'); + unreadBadge.innerText = (Number(unreadBadge.innerHTML) || 0) + 1; + } + } + }, + + /** + * Render chat message log inside chat view container + */ + async renderMessages(messages, activeChatName, userID, chatId) { + 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"; + loadMore.addEventListener('click', () => { + this.loadMoreMessages(chatId, userID); + }); + elements.messagesContainer.appendChild(loadMore); + + for (const msg of messages) { + this.appendSingleMessage(msg, userID, chatId); + } + + this.scrollToBottom(); + }, + + async loadMoreMessages(chatId, userId) { + const oldest = document.querySelector('.message-group:first-of-type'); + const oldestTimestamp = oldest.dataset.timestamp; + const oldestId = oldest.id; + console.log(oldest) + console.log(oldestId) + console.log(oldestTimestamp) + + const loadMoreButton = document.querySelector('.load-more-btn'); + loadMoreButton.removeEventListener('click', this.loadMoreMessages); + + const msgs = await getMoreChatMessages(chatId, oldestTimestamp, oldestId); + msgs.shift(); + + msgs.forEach(async msg => { + loadMoreButton.after(this.generateMessage(msg, userId, chatId)); + }); + + loadMoreButton.addEventListener('click', this.loadMoreMessages); + }, + + /** + * Append a single message (used for optimistic updates immediately upon sending) + */ + appendSingleMessage(msg, userID, chatId, isLocal = false) { + elements.messagesContainer.appendChild(this.generateMessage(msg, userID, chatId, isLocal)) + }, + + generateTempMessageLink(msg) { + const a = document.createElement('a'); + a.target = "_blank"; + a.href = msg.media.url; + + if (msg._data?.mimetype?.startsWith('image/')) { + const img = document.createElement('img'); + img.classList.add('message-image-attachement'); + img.src = msg.media.url; + a.appendChild(img); + } else { + a.textContent = msg.media.filename || "Download file"; + a.download = msg.media.filename; + } + + return a; + }, + + generateMessage(msg, userID, chatId, isLocal = false) { + const isOutgoing = msg.fromMe || msg.sender === 'me'; + + function getPrevMessageElem() { + return elements.messagesContainer.lastElementChild; + } + + const prevMsgEl = getPrevMessageElem(); + + const groupDiv = document.createElement('div'); + groupDiv.className = `message-group selectable ${isOutgoing ? 'outgoing' : 'incoming'}`; + groupDiv.id = normalizeId(msg._serialized ? msg : msg.id); + groupDiv.dataset.timestamp = msg.timestamp; + groupDiv.dataset.from = msg.participant || msg.from; + + const senderName = isOutgoing ? userID : (msg._data.notifyName || msg.from); + const timeStr = formatTime(msg.timestamp || new Date()); + + let statusCheck = ''; + if (isOutgoing) { + if (msg.status === 'read') { + statusCheck = ''; + } else if (msg.status === 'delivered') { + statusCheck = ''; + } else if (msg.status === 'sending') { + statusCheck = ''; + } else { + statusCheck = ''; + } + } + + const bubble = document.createElement('div'); + bubble.className = 'message-bubble'; + + let prevUid; + + if (msg.participant) { + prevUid = msg.participant; + } else { + prevUid = msg.from; + } + + 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; + + if (isLocal) { + a = this.generateTempMessageLink(msg); + } else { + a = document.createElement('a'); + a.innerText = `[Request media]`; + a.target = "_blank"; + + const clickListener = async (e) => { + a.removeEventListener('click', clickListener); + a.innerText = `[Downloading]`; + const mediaMsg = msg.media ? msg : await getMessage(chatId, normalizeId(msg._serialized ? msg : msg.id), true); + if (!mediaMsg || !mediaMsg?.media.url) { + a.addEventListener('click', clickListener); + a.innerText = `[Error, click to try again]` + return; + } + const url = new URL(mediaMsg.media.url); + const reqID = url.pathname.split('/').filter(Boolean).pop(); + + const { blob, filename } = await getMedia(reqID); + + const objectUrl = URL.createObjectURL(blob); + e.target.href = objectUrl; + + if (blob.type.startsWith('image/')) { + a.textContent = ""; + const img = document.createElement('img'); + img.classList.add('message-image-attachement'); + img.src = objectUrl; + bubble.after(img); + bubble.querySelector('.message-content').remove(); + } else { + e.target.textContent = filename || `Download ${mediaMsg.media.filename}`; + } + } + + a.addEventListener('click', clickListener); + } + + 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'); + } else { + groupDiv.appendChild(document.createElement('div')).className = 'message-indicator'; + } + } else if (msg.participant) { + if (!prevMsgEl || prevUid !== prevMsgEl.dataset.from) { + groupDiv.appendChild(document.createElement('div')).className = 'message-indicator'; + } else groupDiv.classList.add('same-sender'); + } else { + if (!prevMsgEl || prevUid !== prevMsgEl.dataset.from) { + groupDiv.appendChild(document.createElement('div')).className = 'message-indicator'; + } else groupDiv.classList.add('same-sender'); + } + + groupDiv.appendChild(bubble); + return groupDiv; + }, + + updateMessage(originalMsgId, generatedMsg) { + const originalMsg = document.querySelector(`#${originalMsgId}`); + if (originalMsg) { + originalMsg.replaceWith(generatedMsg) + } + }, + + updateMessageTick(id, status) { + let statusCheck; + if (status === 'read') { + statusCheck = ''; + } else if (status === 'delivered') { + statusCheck = ''; + } else if (status === 'sending') { + statusCheck = ''; + } else { + statusCheck = ''; + } + + document.getElementById(id).querySelector('.message-meta').outerHTML = statusCheck; + }, + + toggleChatBottomBar() { + elements.chatBottomBar.classList.toggle("collapsed"); + }, + + removeChatMessage(msgId) { + const message = document.getElementById(msgId); + if (message) message.remove(); + } +}; diff --git a/web/src/utils.ts b/js/utils.js similarity index 69% rename from web/src/utils.ts rename to js/utils.js index 261f1bf..81f05f0 100644 --- a/web/src/utils.ts +++ b/js/utils.js @@ -1,13 +1,13 @@ -import type { Message, MessageWithTime } from "./types"; +// Helper utilities and algorithms /** * Format timestamps (supports Unix epoch seconds/ms, strings and ISO dates) * @param {string|number|Date} dateVal * @returns {string} Formatted HH:MM AM/PM string */ -export function formatTime(dateVal: string | number | Date): string { +export function formatTime(dateVal) { if (!dateVal) return ''; - let date: Date; + let date; if (typeof dateVal === 'number') { date = new Date(dateVal < 10000000000 ? dateVal * 1000 : dateVal); } else if (typeof dateVal === 'string' && /^\d+$/.test(dateVal)) { @@ -18,12 +18,12 @@ export function formatTime(dateVal: string | number | Date): string { } let hours = date.getHours(); - const minutes = date.getMinutes(); + let minutes = date.getMinutes(); const ampm = hours >= 12 ? 'PM' : 'AM'; hours = hours % 12; hours = hours ? hours : 12; - const minutesStr = minutes < 10 ? '0' + minutes : String(minutes); - return `${hours}:${minutesStr} ${ampm}`; + minutes = minutes < 10 ? '0' + minutes : minutes; + return `${hours}:${minutes} ${ampm}`; } /** @@ -32,22 +32,24 @@ export function formatTime(dateVal: string | number | Date): string { * @param {Array} messages List of raw messages from WAHA * @returns {Array} Compensated chronological message array */ -export function compensateMessageOrdering(messages: Message[]): Message[] { +export function compensateMessageOrdering(messages) { if (!Array.isArray(messages)) return []; - // convert to ms - const msgs: MessageWithTime[] = messages.map(m => { - let t: number; - if (typeof m.timestamp === 'number') { - t = m.timestamp < 10000000000 ? m.timestamp * 1000 : m.timestamp; + // Convert timestamps to numeric milliseconds for stable comparison + const msgs = messages.map(m => { + let t = m.timestamp; + if (typeof t === 'number') { + if (t < 10000000000) t = t * 1000; } else { - t = new Date(m.timestamp).getTime(); + t = new Date(t).getTime(); } return { ...m, _time: t }; }); + // Initial chronological sort msgs.sort((a, b) => a._time - b._time); + // Apply drift bubble adjustments let changed = true; while (changed) { changed = false; @@ -56,12 +58,12 @@ export function compensateMessageOrdering(messages: Message[]): Message[] { const next = msgs[i + 1]; const timeDiff = next._time - current._time; - // swap if outgoing message is sorted before incoming message within 30-sec window + // Swap if an outgoing message is sorted before an incoming message within a 30-sec window if (current.fromMe && !next.fromMe && timeDiff >= 0 && timeDiff <= 30000) { msgs[i] = next; msgs[i + 1] = current; - // advance the outgoing message timestamp to exactly 1 sec after the incoming + // Advance the outgoing message's timestamp to be exactly 1 second after the incoming one current._time = next._time + 1000; if (typeof current.timestamp === 'number') { current.timestamp = Math.floor(current._time / 1000); @@ -74,16 +76,16 @@ export function compensateMessageOrdering(messages: Message[]): Message[] { } } - // clean temp property + // Clean up temporary property return msgs.map(({ _time, ...m }) => m); } -export function getBase64(file: File): Promise { +export function getBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { - resolve((reader.result as string).split(",")[1]); + resolve(reader.result.split(",")[1]); }; reader.onerror = (e) => { @@ -104,7 +106,7 @@ export function getBase64(file: File): Promise { * @param {string|object} raw * @returns {string|null} */ -export function normalizeId(raw: string | { _serialized?: string; user?: string } | null | undefined): string | null { +export function normalizeId(raw) { if (!raw) return null; if (typeof raw === 'object') { return raw._serialized || raw.user || JSON.stringify(raw); @@ -112,10 +114,10 @@ export function normalizeId(raw: string | { _serialized?: string; user?: string return raw; } -export function debounce(func: () => void, delay: number): () => void { - let timeoutId: ReturnType; +export function debounce(func, delay) { + let timeoutId; return function() { clearTimeout(timeoutId); timeoutId = setTimeout(func, delay); }; -} +} \ No newline at end of file diff --git a/web/src/waha.ts b/js/waha.js similarity index 53% rename from web/src/waha.ts rename to js/waha.js index 353c973..2c5c077 100644 --- a/web/src/waha.ts +++ b/js/waha.js @@ -1,19 +1,19 @@ -import { config } from "./config"; -import { showNotification } from "./notification"; -import { getBase64 } from "./utils"; -import type { Message, VersionResponse, AppUser, ContactInfo, UserAboutResponse, ChatPictureResponse, StatusResponse } from "./types"; +import { config } from "./config.js"; +import { getBase64 } from "./utils.js"; -async function request(path: string, options: RequestInit = {}): Promise { +async function request(path, options = {}) { const url = `${config.wahaUrl}${path}`; - const headers: Record = { + const headers = { 'Content-Type': 'application/json', 'accept': '*/*', - ...(options.headers as Record) + ...options.headers }; if (config.apiKey) { headers['X-Api-Key'] = config.apiKey; } + // console.log(`[WAHA] ${options.method || 'GET'} ${url}`, options.body ? JSON.parse(options.body) : ''); + const response = await fetch(url, { ...options, headers }); if (!response.ok) { let errorDetail = ''; @@ -24,24 +24,24 @@ async function request(path: string, options: RequestInit = {}): Promise { } catch (_) { errorDetail = await response.text().catch(() => ''); } - - showNotification("API Error", `WAHA API returned ${response.status}: ${response.statusText} — ${errorDetail}`, 4000); throw new Error(`WAHA API returned ${response.status}: ${response.statusText} — ${errorDetail}`); } return response.json(); } -async function downloadFile(path: string, options: RequestInit = {}): Promise<{ blob: Blob, filename: string }> { +async function downloadFile(path, options = {}) { const url = `${config.wahaUrl}${path}`; - const headers: Record = { - 'Content-Type': (options.headers as Record | undefined)?.['Content-Type'] ?? 'application/json', + const headers = { + 'Content-Type': options.headers?.['Content-Type'] ?? 'application/json', 'accept': '*/*', - ...(options.headers as Record) + ...options.headers }; if (config.apiKey) headers['X-Api-Key'] = config.apiKey; +// console.log(`[WAHA] ${options.method || 'GET'} ${url}`); + const response = await fetch(url, { ...options, headers }); if (!response.ok) { @@ -66,80 +66,82 @@ async function downloadFile(path: string, options: RequestInit = {}): Promise<{ return { blob, filename }; } + export const waha = { - async getVersion(): Promise { - return await request('/api/version'); + async getVersion() { + return await request('/api/version'); }, - async getChats(): Promise { - const data = await request(`/api/${config.session}/chats`); + async getChats() { + const data = await request(`/api/${config.session}/chats`); return data.map(chat => { let chatId = chat.id; if (chatId && typeof chatId === "object") { chatId = chatId._serialized || chatId.user || JSON.stringify(chatId); } return { - id: chatId || chat.chatId || null, - name: chat.name || null, + id: chatId || chat.chatId || chat.name, + name: chat.name || "Unknown Contact", unreadCount: chat.unreadCount || 0, - lastMessage: chat.lastMessage?.body || chat.lastMessageText || null, - timestamp: chat.lastMessage?.timestamp || null + lastMessage: chat.lastMessage?.body || chat.lastMessageText || "Click to open chat", + timestamp: chat.lastMessage?.timestamp || new Date() }; }); }, - async getChatMessages(chatId: string, beforeTimestamp?: any): Promise { - return request(`/api/${config.session}/chats/${chatId}/messages?downloadMedia=false&limit=40${beforeTimestamp ? `&filter.timestamp.lte=${beforeTimestamp}` : "" }`); + async getChatMessages(chatId, beforeTimestamp) { + console.log(`/api/${config.session}/chats/${chatId}/messages?downloadMedia=false&limit=40&sortBy=timestamp${beforeTimestamp ? `&filter.timestamp.gte=${beforeTimestamp}` : "" }`); + return request(`/api/${config.session}/chats/${chatId}/messages?downloadMedia=false&limit=40${beforeTimestamp ? `&filter.timestamp.lte=${beforeTimestamp}` : "" }`); }, - async getSingleChatMessage(chatId: string, messageId: string, downloadMedia: boolean): Promise { - return request(`/api/${config.session}/chats/${chatId}/messages/${messageId}?downloadMedia=${downloadMedia}`); + async getSingleChatMessage(chatId, messageId, downladMedia) { + return request(`/api/${config.session}/chats/${chatId}/messages/${messageId}?downloadMedia=${downladMedia}`); }, - async getChatPicture(chatId: string): Promise { - return request(`/api/${config.session}/chats/${chatId}/picture`); + async getChatPicture(chatId) { + return request(`/api/${config.session}/chats/${chatId}/picture`); }, - async getUser(chatId: string): Promise { - return request(`/api/${config.session}/contacts/${chatId}`); + async getUser(chatId) { + return request(`/api/${config.session}/contacts/${chatId}`); }, - async getUserAbout(chatId: string): Promise { - return request(`/api/contacts/about?contactId=${chatId}&session=${config.session}`); + async getUserAbout(chatId) { + return request(`/api/contacts/about?contactId=${chatId}&session=${config.session}`); }, - async readChat(chatId: string): Promise { + async readChat(chatId) { return request('/api/sendSeen', { method: 'POST', body: JSON.stringify({ chatId, session: config.session }) }); }, - async downloadMedia(file: string): Promise<{ blob: Blob, filename: string }> { + async downloadMedia(file) { const { blob, filename } = await downloadFile(`/api/files/${config.session}/${file}`); return { blob, filename }; }, - async getMyInfo(): Promise { - return request(`/api/sessions/${config.session}/me`); + async getMyInfo() { + return request(`/api/sessions/${config.session}/me`); }, - async startTyping(chatId: string): Promise { + async startTyping(chatId) { return request('/api/startTyping', { method: 'POST', body: JSON.stringify({ chatId, session: config.session }) }); }, - async stopTyping(chatId: string): Promise { + async stopTyping(chatId) { return request('/api/stopTyping', { method: 'POST', body: JSON.stringify({ chatId, session: config.session }) }); }, - async sendTextMessage(chatId: string, text: string): Promise { - return request('/api/sendText', { + async sendTextMessage(chatId, text) { + return request('/api/sendText', { method: 'POST', body: JSON.stringify({ chatId, @@ -149,8 +151,8 @@ export const waha = { }); }, - async setStatus(text: string): Promise { - return request(`/api/${config.session}/profile/status`, { + async setStatus(text) { + return request(`/api/${config.session}/profile/status`, { method: 'PUT', body: JSON.stringify({ status: text @@ -158,9 +160,9 @@ export const waha = { }); }, - async sendFileMessage(chatId: string, file: File): Promise { + async sendFileMessage(chatId, file) { const fileBase64 = await getBase64(file); - const body: RequestInit = { + const body = { method: 'POST', body: JSON.stringify({ chatId, @@ -178,7 +180,7 @@ export const waha = { if (file.type.startsWith('image/')) endpoint = '/api/sendImage'; if (file.type.startsWith('video/')) endpoint = '/api/sendVideo'; - const result = await request(endpoint, body); + const result = await request(endpoint, body); return result; } }; diff --git a/web/src/websocket.ts b/js/websocket.js similarity index 78% rename from web/src/websocket.ts rename to js/websocket.js index dd6e86f..c1c0aa2 100644 --- a/web/src/websocket.ts +++ b/js/websocket.js @@ -1,14 +1,13 @@ -import { config } from "./config"; -import { isOnline, updateOnlineStatus } from "./storage"; -import type { WebSocketEvent } from "./types"; +import { config } from "./config.js"; +import { isOnline, updateOnlineStatus } from "./storage.js"; -let socket: WebSocket | null = null; -let reconnectTimer: ReturnType | null = null; -let currentOnMessageCallback: ((data: WebSocketEvent) => void) | null = null; +let socket = null; +let reconnectTimer = null; +let currentOnMessageCallback = null; export const websocket = { - connect(onMessageCallback: (data: WebSocketEvent) => void) { - if (!isOnline()) return; + connect(onMessageCallback) { + if (!isOnline) return; currentOnMessageCallback = onMessageCallback; this.disconnect(false); @@ -49,7 +48,7 @@ export const websocket = { socket.onmessage = (event) => { try { - const data = JSON.parse(event.data) as WebSocketEvent; + const data = JSON.parse(event.data); if (currentOnMessageCallback) { currentOnMessageCallback(data); } @@ -65,13 +64,10 @@ export const websocket = { socket.onclose = (event) => { console.log(`[WS] Connection closed (code: ${event.code}). Reconnecting in 5 seconds...`); socket = null; - + reconnectTimer = setTimeout(() => { - updateOnlineStatus().then(() => { - if (currentOnMessageCallback) { - this.connect(currentOnMessageCallback); - } - }); + updateOnlineStatus(); + this.connect(currentOnMessageCallback); }, 5000); }; } catch (e) { diff --git a/web/loading.css b/loading.css similarity index 100% rename from web/loading.css rename to loading.css diff --git a/web/public/manifest.json b/manifest.json similarity index 100% rename from web/public/manifest.json rename to manifest.json diff --git a/web/public/message-long.ogg b/message-long.ogg similarity index 100% rename from web/public/message-long.ogg rename to message-long.ogg diff --git a/web/public/message.ogg b/message.ogg similarity index 100% rename from web/public/message.ogg rename to message.ogg diff --git a/web/metroicons.css b/metroicons.css similarity index 100% rename from web/metroicons.css rename to metroicons.css diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 7ebca84..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1249 +0,0 @@ -{ - "name": "pandora", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "pandora", - "version": "1.0.0", - "license": "ISC", - "devDependencies": { - "typescript": "^7.0.2", - "vite": "^8.1.5" - } - }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@typescript/typescript-aix-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", - "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", - "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-darwin-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", - "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", - "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-freebsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", - "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", - "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", - "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-loong64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", - "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-mips64el": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", - "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-ppc64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", - "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-riscv64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", - "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-s390x": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", - "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-linux-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", - "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.20", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", - "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.139.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/typescript": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", - "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc" - }, - "engines": { - "node": ">=16.20.0" - }, - "optionalDependencies": { - "@typescript/typescript-aix-ppc64": "7.0.2", - "@typescript/typescript-darwin-arm64": "7.0.2", - "@typescript/typescript-darwin-x64": "7.0.2", - "@typescript/typescript-freebsd-arm64": "7.0.2", - "@typescript/typescript-freebsd-x64": "7.0.2", - "@typescript/typescript-linux-arm": "7.0.2", - "@typescript/typescript-linux-arm64": "7.0.2", - "@typescript/typescript-linux-loong64": "7.0.2", - "@typescript/typescript-linux-mips64el": "7.0.2", - "@typescript/typescript-linux-ppc64": "7.0.2", - "@typescript/typescript-linux-riscv64": "7.0.2", - "@typescript/typescript-linux-s390x": "7.0.2", - "@typescript/typescript-linux-x64": "7.0.2", - "@typescript/typescript-netbsd-arm64": "7.0.2", - "@typescript/typescript-netbsd-x64": "7.0.2", - "@typescript/typescript-openbsd-arm64": "7.0.2", - "@typescript/typescript-openbsd-x64": "7.0.2", - "@typescript/typescript-sunos-x64": "7.0.2", - "@typescript/typescript-win32-arm64": "7.0.2", - "@typescript/typescript-win32-x64": "7.0.2" - } - }, - "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index f8b9b76..0000000 --- a/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "pandora", - "version": "1.0.0", - "description": "\"PANDORA\"", - "main": "index.js", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "tsc && vite build && vite preview" - }, - "keywords": [], - "author": "", - "license": "ISC", - "devDependencies": { - "typescript": "^7.0.2", - "vite": "^8.1.5" - } -} diff --git a/web/style.css b/style.css similarity index 92% rename from web/style.css rename to style.css index 387b52a..ddc4e0b 100644 --- a/web/style.css +++ b/style.css @@ -93,6 +93,9 @@ 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 { @@ -126,44 +129,16 @@ 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-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; + overflow: hidden; + position: relative; } .app-name { font-weight: bold; } -.dim { - opacity: .6; -} - /* Sidebar Styling */ .sidebar { width: 380px; @@ -465,7 +440,6 @@ input:focus { .api-status-badge { display: flex; align-items: center; - justify-content: center; gap: 8px; font-size: 0.75rem; color: var(--text-secondary); @@ -502,7 +476,6 @@ input:focus { display: flex; flex-direction: column; position: relative; - height: 100%; } /* Empty State */ @@ -870,14 +843,8 @@ input:focus { /* Extra Pages */ .extra-page { - display: flex; - flex-direction: column; + display: none; width: 100%; - height: 100%; - scroll-snap-align: start; - scroll-snap-stop: always; - flex: 0 0 100%; - } .extra-page-content { @@ -904,7 +871,7 @@ input:focus { flex: 1; } -.big-title { +#profile-page h2 { padding: 1.4rem; font-size: 5rem; text-wrap-mode: nowrap; @@ -932,27 +899,6 @@ input:focus { border-bottom-color: var(--text-primary); } - -.notification-box { - transition: transform 1s cubic-bezier(0.19, 1, 0.22, 1); - width: 100%; - position: fixed; - top: 0; - left: 0; - background-color: var(--bg-secondary); - transform: translateY(-100%); - padding: .4em; - text-transform: uppercase; -} - -.notification-box.shown { - transform: none; -} - -.notification-box h1 { - font-size: 1rem; -} - @media (max-width: 768px) { .app-container { width: 100%; @@ -964,12 +910,13 @@ input:focus { scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; - scrollbar-width: none; - -ms-overflow-style: none; + /* Hide scrollbars */ + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ } .app-container::-webkit-scrollbar { - display: none; + display: none; /* Chrome, Safari, Opera */ } .sidebar { @@ -997,7 +944,7 @@ input:focus { } .extra-page { - display: flex; + display: block; width: 100%; flex: 0 0 100%; position: relative; @@ -1046,6 +993,7 @@ input:focus { .modal-content { width: 100%; background: var(--bg-main); + padding: 24px; box-shadow: var(--shadow-lg); backdrop-filter: var(--glass-blur); display: flex; @@ -1062,7 +1010,7 @@ input:focus { display: flex; justify-content: space-between; align-items: center; - padding: 1em 2em 0em 2em; + margin-bottom: 20px; } .modal-header h2 { @@ -1074,11 +1022,6 @@ 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 { @@ -1103,7 +1046,7 @@ input:focus { display: flex; justify-content: flex-end; gap: 12px; - padding: 1rem 2rem; + margin-top: auto; } .btn { @@ -1116,15 +1059,8 @@ input:focus { } .primary-btn { - 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); + background: var(--accent-gradient); + color: white; } .secondary-btn { diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 3af771c..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "isolatedModules": true, - "skipLibCheck": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "outDir": "./dist", - "rootDir": "./web/src", - "sourceMap": true - }, - "include": ["web/src"] -} diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index bdf4746..0000000 --- a/vite.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineConfig } from 'vite'; - -export default defineConfig({ - root: 'web', - base: '/pandora/', - build: { - outDir: '../dist', - emptyOutDir: true, - }, -}); diff --git a/web/app.html b/web/app.html deleted file mode 100644 index 2f7be0d..0000000 --- a/web/app.html +++ /dev/null @@ -1,175 +0,0 @@ - - - - - - - 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 deleted file mode 100644 index ab8d213..0000000 --- a/web/index.html +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - Pandora - - - - - - -
    -
    -

    Pandora

    - -
    -
    - -
    -
    -

    Connecting

    - -
    -
    - - - - diff --git a/web/src/notification.ts b/web/src/notification.ts deleted file mode 100644 index 3b25164..0000000 --- a/web/src/notification.ts +++ /dev/null @@ -1,37 +0,0 @@ -export async function showNotification(title: string, subtitle: string, time: number = 5000, _hint?: string): Promise { - const notificationBox = document.createElement('div'); - notificationBox.classList.add('notification-box'); - - const notificationTitle = document.createElement('h1'); - notificationTitle.innerHTML = title; - - const notificationSubtitle = document.createElement('p'); - notificationSubtitle.innerHTML = subtitle; - - notificationBox.appendChild(notificationTitle); - if (subtitle) { - notificationBox.appendChild(notificationSubtitle); - } - document.querySelector('body')!.appendChild(notificationBox); - - let clicked = false; - - notificationBox.addEventListener('click', () => { - clicked = true; - hideNotification(notificationBox); - }) - - await new Promise(requestAnimationFrame); - notificationBox.classList.add("shown"); - - await new Promise(r => setTimeout(r, time)); - if (!clicked) { - hideNotification(notificationBox); - } -} - -async function hideNotification(notificationBox: HTMLDivElement): Promise { - notificationBox.classList.remove('shown'); - await new Promise(r => setTimeout(r, 1000)); - notificationBox.remove(); -} diff --git a/web/src/setup.ts b/web/src/setup.ts deleted file mode 100644 index 5be0007..0000000 --- a/web/src/setup.ts +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index f707e88..0000000 --- a/web/src/states.ts +++ /dev/null @@ -1,7 +0,0 @@ -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/types.ts b/web/src/types.ts deleted file mode 100644 index d65614c..0000000 --- a/web/src/types.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** Represents a chat entry in the sidebar */ -export interface Chat { - id: string; - name: string; - lastMessage: string; - timestamp: number | string | Date; - unreadCount: number; -} - -/** Raw chat object returned from the WAHA API */ -export interface WahaChat { - id: string | { _serialized?: string; user?: string }; - chatId?: string; - name?: string; - unreadCount?: number; - lastMessage?: { - body?: string; - timestamp?: number | string; - }; - lastMessageText?: string; -} - -/** Message data sub-object (_data field) */ -export interface MessageData { - notifyName?: string; - mimetype?: string; - [key: string]: unknown; -} - -/** Media attachment info */ -export interface MediaInfo { - url: string; - filename?: string; -} - -/** A WhatsApp message */ -export interface Message { - _data?: MessageData; - _serialized?: string | { _serialized?: string; user?: string }; - id: string | { _serialized?: string; user?: string }; - body?: string; - text?: string; - from?: string; - to?: string; - fromMe?: boolean; - sender?: string; - timestamp: number | string; - status?: string; - ack?: number; - hasMedia?: boolean; - media?: MediaInfo; - chatId?: string; - chat?: { id: string }; - participant?: string; -} - -/** Temporary message used for optimistic UI updates */ -export interface TempMessage extends Message { - status: string; -} - -/** File-sending temporary message */ -export interface TempFileMessage extends TempMessage { - _data: MessageData; - hasMedia: true; - media: MediaInfo; -} - -/** WAHA version response */ -export interface VersionResponse { - version?: string; -} - -/** WAHA user info (getMyInfo) */ -export interface AppUser { - id: string; - pushName?: string; - name?: string; -} - -/** WAHA contact info */ -export interface ContactInfo { - number: string; - [key: string]: unknown; -} - -/** WAHA user about response */ -export interface UserAboutResponse { - about?: string; -} - -/** Chat picture response */ -export interface ChatPictureResponse { - url?: string; -} - -/** Status update response */ -export interface StatusResponse { - success?: boolean; -} - -/** WebSocket event payload envelope */ -export interface WebSocketEvent { - event: string; - payload: Message; -} - -/** IndexedDB stored media record */ -export interface StoredMedia { - reqId: string; - blob: Blob; - filename: string; -} - -/** Downloaded media result */ -export interface DownloadedMedia { - blob: Blob; - filename: string; -} - -/** Message with _time field used during ordering compensation */ -export interface MessageWithTime extends Message { - _time: number; -} diff --git a/web/src/ui.ts b/web/src/ui.ts deleted file mode 100644 index 13f14bf..0000000 --- a/web/src/ui.ts +++ /dev/null @@ -1,553 +0,0 @@ -import { formatTime, normalizeId } from "./utils"; -import { getChatPicture, getMessage, getMedia, getMoreChatMessages } from "./storage"; -import type { Chat, Message } from "./types"; -import { activeChatState } from "./states"; - -export const elements = { - chatList: document.getElementById('chat-list') as HTMLUListElement, - chatsLoader: document.getElementById('chats-loader') as HTMLDivElement, - chatSearch: document.getElementById('chat-search') as HTMLInputElement, - backendStatusText: document.getElementById('backend-status-text') as HTMLSpanElement, - apiStatusIndicator: document.querySelector('.pulse-dot') as HTMLSpanElement, - noChatState: document.getElementById('no-chat-state') as HTMLDivElement, - activeChatContainer: document.getElementById('active-chat-container') as HTMLDivElement, - activeChatName: document.getElementById('active-chat-name') as HTMLHeadingElement, - activeChatAvatar: document.getElementById('active-chat-avatar') as HTMLDivElement, - messagesContainer: document.getElementById('messages-container') as HTMLDivElement, - messageForm: document.getElementById('message-form') as HTMLFormElement, - messageInput: document.getElementById('message-input') as HTMLInputElement, - backToSidebarBtn: document.querySelector('.chat-header') as HTMLElement, - sidebar: document.querySelector('.sidebar') as HTMLElement, - appContainer: document.querySelector('.app-container') as HTMLElement, - 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, - inputBackgroundImage: document.getElementById('settings-background-image') as HTMLInputElement, - inputBackgroundOpacity: document.getElementById('settings-background-opacity') as HTMLInputElement, - loggedUserName: document.getElementById('pandora-username') as HTMLHeadingElement, - chatBottomBar: document.getElementById('chat-bottom-bar') as HTMLElement, - chatBottomBarBtn: document.getElementById('chat-bottom-bar-btn') as HTMLButtonElement, - chatInputPanel: document.getElementById('chat-input-panel') as HTMLElement, - attachmentInput: document.getElementById('attachment-input') as HTMLInputElement, - attachmentBtn: document.getElementById('attachment-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, - 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 views = new Map; - -export const ui = { - /** - * Switch view state when a contact chat is opened or closed - */ - toggleChatState(hasActive: boolean) { - if (hasActive) { - elements.noChatState.classList.add('hidden'); - elements.activeChatContainer.classList.remove('hidden'); - } else { - elements.noChatState.classList.remove('hidden'); - elements.activeChatContainer.classList.add('hidden'); - } - }, - - /** - * Scroll message list automatically to bottom - */ - scrollToBottom() { - 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 - */ - updateConnectionStatus(isConnected: boolean, text: string) { - elements.backendStatusText.textContent = text; - if (isConnected) { - elements.apiStatusIndicator.style.backgroundColor = 'var(--online-color)'; - elements.apiStatusIndicator.style.animation = 'pulse 1.8s infinite'; - } else { - elements.apiStatusIndicator.style.backgroundColor = '#ef4444'; - elements.apiStatusIndicator.style.animation = 'none'; - } - }, - - async renderChatList(chats: Chat[], activeChat: Chat | null, onChatSelect: (chat: Chat) => void) { - elements.chatList.innerHTML = ''; - chats.sort((a, b) => { - const timeA = new Date(a.timestamp).getTime(); - 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 = ` -
    - ${initials} -
    -
    -
    - ${chat.name} - ${timeStr} -
    -
    - - ${chat.lastMessage || '...'} - - ${hasUnread ? `${chat.unreadCount}` : ''} -
    -
    - `; - - li.addEventListener('click', () => onChatSelect(chat)); - elements.chatList.appendChild(li); - - (async () => { - try { - const picture = await getChatPicture(chat.id); - const img = li.querySelector(`img[data-chat-avatar="${chat.id}"]`) as HTMLImageElement; - if (img) img.src = picture.url ? picture.url : ''; - } catch (e) { - } - })(); - } - }, - - 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()); - - if (!msg.fromMe && (!activeChatState || activeChatState.id !== (msg.fromMe ? msg.to : msg.from))) { - const unreadBadge = chatNode.querySelector('.unread-badge') as HTMLElement; - if (unreadBadge) { - unreadBadge.innerText = (Number(unreadBadge.innerHTML) || 0) + 1 + ""; - } else { - const preview = chatNode.querySelector('.chat-item-preview') as HTMLElement; - const badge = document.createElement('span'); - badge.className = 'unread-badge'; - badge.innerText = '1'; - preview.appendChild(badge); - } - } - } - }, - - 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; - messageItem.innerText = chat.lastMessage || 'Media message'; - time.innerText = chat.timestamp ? formatTime(chat.timestamp) : formatTime(Date.now()); - const unreadBadge = chatNode.querySelector('.unread-badge') as HTMLElement; - const newCount = (Number(unreadBadge?.innerHTML) || 0); - if (unreadBadge && newCount != 0) { - unreadBadge.innerText = newCount + ""; - } else { - if (unreadBadge) unreadBadge.remove(); - } - } - }, - - /** - * 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"; - loadMore.onclick = () => { - 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'); - img.src = msg.media.url; - a.appendChild(img); - } else { - a.textContent = msg.media.filename || "Download file"; - 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') { - statusCheck = ''; - } else if (msg.status === 'delivered') { - statusCheck = ''; - } else if (msg.status === 'sending') { - statusCheck = ''; - } else { - 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]`; - const mediaMsg = msg.media ? msg : await getMessage(chatId, normalizeId(msg._serialized ? (msg._serialized as any) : msg.id) || "", true); - if (!mediaMsg || !mediaMsg?.media?.url) { - a.addEventListener('click', clickListener); - a.innerText = `[Error, click to try again]` - return; - } - 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; - 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); - } - - 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'); - } else { - const indicator = document.createElement('div'); - indicator.className = 'message-indicator'; - groupDiv.appendChild(indicator); - } - } else if (msg.participant) { - if (!prevMsgEl || prevUid !== prevMsgEl.dataset.from) { - const indicator = document.createElement('div'); - indicator.className = 'message-indicator'; - groupDiv.appendChild(indicator); - } else groupDiv.classList.add('same-sender'); - } else { - if (!prevMsgEl || prevUid !== prevMsgEl.dataset.from) { - const indicator = document.createElement('div'); - indicator.className = 'message-indicator'; - 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') { - statusCheck = ''; - } else if (status === 'delivered') { - statusCheck = ''; - } else if (status === 'sending') { - statusCheck = ''; - } 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