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();