diff --git a/ui/src/App.tsx b/ui/src/App.tsx
deleted file mode 100644
index b513673..0000000
--- a/ui/src/App.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Button } from "./components/ui/button";
-import { useQuery } from "@tanstack/react-query";
-import { useWAClient } from "./lib/context";
-
-export default function App() {
- const { client, isReady } = useWAClient();
- const { data: chats } = useQuery({
- queryKey: ["chats"],
- queryFn: () => client.listChats({ limit: 50 }),
- enabled: isReady,
- });
-
- console.log(chats);
-
- return (
-
-
-
- );
-}
diff --git a/ui/src/app/chats.tsx b/ui/src/app/chats.tsx
index 1f55969..4d1585f 100644
--- a/ui/src/app/chats.tsx
+++ b/ui/src/app/chats.tsx
@@ -1,4 +1,4 @@
-import { useWAClient } from "@/lib/context";
+import { useWAClient } from "./context";
import { cn, getId, getInitial } from "@/lib/utils";
import type { ChatDTO } from "@shared/dto";
import { useQuery } from "@tanstack/react-query";
diff --git a/ui/src/lib/context.tsx b/ui/src/app/context.tsx
similarity index 97%
rename from ui/src/lib/context.tsx
rename to ui/src/app/context.tsx
index 1beedfd..23910db 100644
--- a/ui/src/lib/context.tsx
+++ b/ui/src/app/context.tsx
@@ -1,7 +1,7 @@
import { createContext, useContext, useEffect, useRef, useState } from "react";
import type { WhatsAppClient } from "@client/whatsapp";
import { useQuery } from "@tanstack/react-query";
-import wa from "./wa-client";
+import wa from "../lib/wa-client";
export const WAContext = createContext<{
client: WhatsAppClient;
diff --git a/ui/src/app/messages.tsx b/ui/src/app/messages.tsx
index 39ed8b2..7575ed5 100644
--- a/ui/src/app/messages.tsx
+++ b/ui/src/app/messages.tsx
@@ -1,4 +1,4 @@
-import { useWAClient } from "@/lib/context";
+import { useWAClient } from "./context";
import { getFormattedName, getId, getInitial } from "@/lib/utils";
import type { ChatDTO } from "@shared/dto";
import { useQuery } from "@tanstack/react-query";
@@ -7,6 +7,7 @@ import { ArrowLeftIcon, PaperclipIcon, SendIcon } from "lucide-react";
import React, { useMemo, useState } from "react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
+import MessageMedia from "@/components/message-media";
dayjs.extend(relativeTime);
@@ -34,15 +35,9 @@ export default function MessagesSection({
const chatId = getId(chat.id);
const { data: messages, refetch } = useQuery({
queryKey: ["messages", chatId],
- queryFn: () =>
- client.getMessages({ chatId, count: 50 }).then((res) =>
- res.map((i) => ({
- ...i,
- id: getId(i.id),
- fromMe: i.id.fromMe || i.fromMe,
- })),
- ),
+ queryFn: () => client.getMessages({ chatId, count: 50 }),
enabled: Boolean(chatId && isReady),
+ refetchInterval: 10_1000,
});
const messageList = useMemo(() => {
@@ -54,6 +49,7 @@ export default function MessagesSection({
);
return {
...message,
+ id: getId(message.id),
from,
};
})
@@ -76,19 +72,6 @@ export default function MessagesSection({
}
}
- async function fetchMedia(msgId: string) {
- const data = await client.getMedia(msgId);
- // decode from base64
- const blob = await fetch(data).then((res) => res.blob());
- // download
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = "media";
- a.click();
- URL.revokeObjectURL(url);
- }
-
return (
- {item.quotedMsg.body}
+
+ {item.quotedMsg.body}
+
) : null}
- {item.type === "image" && (
- fetchMedia(item.id)}>
-
-
- )}
+ {item.type === "image" ||
+ item.type === "sticker" ||
+ item.type === "document" ? (
+
+ ) : null}
{item.type === "chat" || item.caption ? (
-
+
{item.caption || item.body}
) : null}
- {item.type === "sticker" && Sticker
}
-
diff --git a/ui/src/components/message-media.tsx b/ui/src/components/message-media.tsx
new file mode 100644
index 0000000..0d38ceb
--- /dev/null
+++ b/ui/src/components/message-media.tsx
@@ -0,0 +1,140 @@
+import { useWAClient } from "@/app/context";
+import { storage } from "@/lib/storage";
+import type { MessageDTO } from "@shared/dto";
+import { DownloadIcon, Loader2Icon, PaperclipIcon } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+
+type Props = {
+ data: Omit
& { id: string };
+};
+
+export default function MessageMedia({ data }: Props) {
+ const { type } = data;
+ const { client } = useWAClient();
+ const loadingRef = useRef(false);
+
+ const [isLoading, setLoading] = useState(false);
+ const [isDownloaded, setDownloaded] = useState(false);
+ const [body, setBody] = useState(
+ type === "image" ? "data:image/webp;base64," + data.body : data.body,
+ );
+
+ useEffect(() => {
+ if (loadingRef.current) return;
+
+ (async function () {
+ try {
+ loadingRef.current = true;
+
+ if (type === "image" || type === "document") {
+ const res = await storage.get(data.id);
+ if (res) {
+ setBody(res);
+ setDownloaded(true);
+ }
+ }
+
+ if (type === "sticker" && !body) {
+ const res = await storage.get(data.id);
+ if (res) {
+ setBody(res);
+ setDownloaded(true);
+ } else {
+ await fetchMedia();
+ }
+ }
+ } catch (err) {
+ console.error(err);
+ } finally {
+ loadingRef.current = false;
+ }
+ })();
+ }, [data.id]);
+
+ async function fetchMedia() {
+ try {
+ setLoading(true);
+ const res = await client.getMedia(data.id);
+ setBody(res);
+ setDownloaded(true);
+ storage.set(data.id, res);
+ return res;
+ } catch (err) {
+ console.error(err);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function openMedia() {
+ if (isLoading) return;
+
+ if (isDownloaded) {
+ // download image
+ const blob = await fetch(body!).then((res) => res.blob());
+ const url = URL.createObjectURL(blob);
+ window.open(url);
+ return;
+ } else {
+ fetchMedia();
+ }
+ }
+
+ if (type === "image") {
+ return (
+ openMedia()}
+ className="w-full max-w-40 block relative overflow-hidden"
+ >
+
+
+ {isLoading && (
+
+
+
+ )}
+
+ );
+ }
+
+ if (type === "sticker" && body) {
+ return (
+
+ );
+ }
+
+ if (type === "document") {
+ return (
+
+ );
+ }
+
+ return null;
+}
+
+function readableBytes(bytes: number) {
+ if (bytes < 1024) return bytes + " B";
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
+ if (bytes < 1024 * 1024 * 1024)
+ return (bytes / 1024 / 1024).toFixed(1) + " MB";
+ return (bytes / 1024 / 1024 / 1024).toFixed(1) + " GB";
+}
diff --git a/ui/src/app/globals.css b/ui/src/globals.css
similarity index 100%
rename from ui/src/app/globals.css
rename to ui/src/globals.css
diff --git a/ui/src/lib/storage.ts b/ui/src/lib/storage.ts
new file mode 100644
index 0000000..ccc6cf1
--- /dev/null
+++ b/ui/src/lib/storage.ts
@@ -0,0 +1,124 @@
+export interface StorageItem {
+ id: string;
+ data: T;
+}
+
+export class Storage {
+ private db: IDBDatabase | null = null;
+ private dbName = "";
+ private storeName = "";
+ private version = 1;
+
+ constructor(dbName = "app-storage", storeName = "data", version = 1) {
+ this.dbName = dbName;
+ this.storeName = storeName;
+ this.version = version;
+ }
+
+ private async getDb(): Promise {
+ if (this.db) return this.db;
+
+ this.db = await new Promise((resolve, reject) => {
+ const request = indexedDB.open(this.dbName, this.version);
+
+ request.onupgradeneeded = () => {
+ const db = request.result;
+
+ if (!db.objectStoreNames.contains(this.storeName)) {
+ db.createObjectStore(this.storeName, {
+ keyPath: "id",
+ });
+ }
+ };
+
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () => reject(request.error);
+ });
+
+ return this.db;
+ }
+
+ async set(id: string, data: T): Promise {
+ const db = await this.getDb();
+
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(this.storeName, "readwrite");
+ const store = tx.objectStore(this.storeName);
+
+ store.put({ id, data });
+
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ tx.onabort = () => reject(tx.error);
+ });
+ }
+
+ async get(id: string): Promise {
+ const db = await this.getDb();
+
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(this.storeName, "readonly");
+ const request = tx.objectStore(this.storeName).get(id);
+
+ request.onsuccess = () => {
+ const item = request.result as StorageItem | undefined;
+ resolve(item?.data);
+ };
+
+ request.onerror = () => reject(request.error);
+ });
+ }
+
+ async has(id: string): Promise {
+ const db = await this.getDb();
+
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(this.storeName, "readonly");
+ const request = tx.objectStore(this.storeName).count(id);
+
+ request.onsuccess = () => resolve(request.result > 0);
+ request.onerror = () => reject(request.error);
+ });
+ }
+
+ async delete(id: string): Promise {
+ const db = await this.getDb();
+
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(this.storeName, "readwrite");
+ tx.objectStore(this.storeName).delete(id);
+
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ tx.onabort = () => reject(tx.error);
+ });
+ }
+
+ async clear(): Promise {
+ const db = await this.getDb();
+
+ await new Promise((resolve, reject) => {
+ const tx = db.transaction(this.storeName, "readwrite");
+ tx.objectStore(this.storeName).clear();
+
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ tx.onabort = () => reject(tx.error);
+ });
+ }
+
+ async keys(): Promise {
+ const db = await this.getDb();
+
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(this.storeName, "readonly");
+ const request = tx.objectStore(this.storeName).getAllKeys();
+
+ request.onsuccess = () => resolve(request.result as string[]);
+
+ request.onerror = () => reject(request.error);
+ });
+ }
+}
+
+export const storage = new Storage();
diff --git a/ui/src/main.tsx b/ui/src/main.tsx
index 40d3c9c..b490407 100644
--- a/ui/src/main.tsx
+++ b/ui/src/main.tsx
@@ -1,9 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
-import { WAProvider } from "./lib/context.tsx";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { WAProvider } from "./app/context.tsx";
import App from "./app/page.tsx";
-import "./app/globals.css";
+import "./globals.css";
const queryCLient = new QueryClient();