feat: add media storage
This commit is contained in:
@@ -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 (
|
|
||||||
<div>
|
|
||||||
<Button>Test</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useWAClient } from "@/lib/context";
|
import { useWAClient } from "./context";
|
||||||
import { cn, getId, getInitial } from "@/lib/utils";
|
import { cn, getId, getInitial } from "@/lib/utils";
|
||||||
import type { ChatDTO } from "@shared/dto";
|
import type { ChatDTO } from "@shared/dto";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
import type { WhatsAppClient } from "@client/whatsapp";
|
import type { WhatsAppClient } from "@client/whatsapp";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import wa from "./wa-client";
|
import wa from "../lib/wa-client";
|
||||||
|
|
||||||
export const WAContext = createContext<{
|
export const WAContext = createContext<{
|
||||||
client: WhatsAppClient;
|
client: WhatsAppClient;
|
||||||
+14
-35
@@ -1,4 +1,4 @@
|
|||||||
import { useWAClient } from "@/lib/context";
|
import { useWAClient } from "./context";
|
||||||
import { getFormattedName, getId, getInitial } from "@/lib/utils";
|
import { getFormattedName, getId, getInitial } from "@/lib/utils";
|
||||||
import type { ChatDTO } from "@shared/dto";
|
import type { ChatDTO } from "@shared/dto";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
@@ -7,6 +7,7 @@ import { ArrowLeftIcon, PaperclipIcon, SendIcon } from "lucide-react";
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
|
import MessageMedia from "@/components/message-media";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
@@ -34,15 +35,9 @@ export default function MessagesSection({
|
|||||||
const chatId = getId(chat.id);
|
const chatId = getId(chat.id);
|
||||||
const { data: messages, refetch } = useQuery({
|
const { data: messages, refetch } = useQuery({
|
||||||
queryKey: ["messages", chatId],
|
queryKey: ["messages", chatId],
|
||||||
queryFn: () =>
|
queryFn: () => client.getMessages({ chatId, count: 50 }),
|
||||||
client.getMessages({ chatId, count: 50 }).then((res) =>
|
|
||||||
res.map((i) => ({
|
|
||||||
...i,
|
|
||||||
id: getId(i.id),
|
|
||||||
fromMe: i.id.fromMe || i.fromMe,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
enabled: Boolean(chatId && isReady),
|
enabled: Boolean(chatId && isReady),
|
||||||
|
refetchInterval: 10_1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const messageList = useMemo(() => {
|
const messageList = useMemo(() => {
|
||||||
@@ -54,6 +49,7 @@ export default function MessagesSection({
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
|
id: getId(message.id),
|
||||||
from,
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`flex min-w-0 flex-1 flex-col bg-muted ${mobileChat ? "flex" : "hidden md:flex"}`}
|
className={`flex min-w-0 flex-1 flex-col bg-muted ${mobileChat ? "flex" : "hidden md:flex"}`}
|
||||||
@@ -174,28 +157,24 @@ export default function MessagesSection({
|
|||||||
!item.fromMe && "bg-[#d8f5e8]/30 dark:bg-[#26855c]/30",
|
!item.fromMe && "bg-[#d8f5e8]/30 dark:bg-[#26855c]/30",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p className="whitespace-pre-wrap">{item.quotedMsg.body}</p>
|
<p className="whitespace-pre-wrap wrap-break-word">
|
||||||
|
{item.quotedMsg.body}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{item.type === "image" && (
|
{item.type === "image" ||
|
||||||
<a href="#" onClick={() => fetchMedia(item.id)}>
|
item.type === "sticker" ||
|
||||||
<img
|
item.type === "document" ? (
|
||||||
src={"data:image/jpeg;base64," + item.body}
|
<MessageMedia data={item} />
|
||||||
alt=""
|
) : null}
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{item.type === "chat" || item.caption ? (
|
{item.type === "chat" || item.caption ? (
|
||||||
<p className="whitespace-pre-wrap">
|
<p className="whitespace-pre-wrap wrap-break-word">
|
||||||
{item.caption || item.body}
|
{item.caption || item.body}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{item.type === "sticker" && <p className="italic">Sticker</p>}
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`mt-1 flex items-center justify-end gap-1 text-[10px] ${item.fromMe ? "text-primary" : "text-muted-foreground"}`}
|
className={`mt-1 flex items-center justify-end gap-1 text-[10px] ${item.fromMe ? "text-primary" : "text-muted-foreground"}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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<MessageDTO, "id" | "from"> & { 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<string>(data.id);
|
||||||
|
if (res) {
|
||||||
|
setBody(res);
|
||||||
|
setDownloaded(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "sticker" && !body) {
|
||||||
|
const res = await storage.get<string>(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 (
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
onClick={() => openMedia()}
|
||||||
|
className="w-full max-w-40 block relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<img src={body || ""} alt="" className="w-full" />
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||||
|
<Loader2Icon className="animate-spin size-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "sticker" && body) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={body}
|
||||||
|
alt=""
|
||||||
|
className="w-full max-w-40 block relative overflow-hidden"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "document") {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => openMedia()}
|
||||||
|
className="flex gap-2 items-center rounded-md bg-accent px-3 py-2 mb-2 truncate w-full cursor-pointer"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2Icon className="animate-spin size-4" />
|
||||||
|
) : isDownloaded ? (
|
||||||
|
<PaperclipIcon className="size-4" />
|
||||||
|
) : (
|
||||||
|
<DownloadIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs truncate">{data.filename}</span>
|
||||||
|
<span className="text-xs">{readableBytes(data.size || 0)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
export interface StorageItem<T = unknown> {
|
||||||
|
id: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Storage<T = unknown> {
|
||||||
|
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<IDBDatabase> {
|
||||||
|
if (this.db) return this.db;
|
||||||
|
|
||||||
|
this.db = await new Promise<IDBDatabase>((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<void> {
|
||||||
|
const db = await this.getDb();
|
||||||
|
|
||||||
|
await new Promise<void>((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<T = any>(id: string): Promise<T | undefined> {
|
||||||
|
const db = await this.getDb();
|
||||||
|
|
||||||
|
return new Promise<T | undefined>((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<T> | undefined;
|
||||||
|
resolve(item?.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async has(id: string): Promise<boolean> {
|
||||||
|
const db = await this.getDb();
|
||||||
|
|
||||||
|
return new Promise<boolean>((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<void> {
|
||||||
|
const db = await this.getDb();
|
||||||
|
|
||||||
|
await new Promise<void>((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<void> {
|
||||||
|
const db = await this.getDb();
|
||||||
|
|
||||||
|
await new Promise<void>((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<string[]> {
|
||||||
|
const db = await this.getDb();
|
||||||
|
|
||||||
|
return new Promise<string[]>((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();
|
||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { WAProvider } from "./lib/context.tsx";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { WAProvider } from "./app/context.tsx";
|
||||||
import App from "./app/page.tsx";
|
import App from "./app/page.tsx";
|
||||||
import "./app/globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const queryCLient = new QueryClient();
|
const queryCLient = new QueryClient();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user