diff --git a/src/client/whatsapp.ts b/src/client/whatsapp.ts index 422b7f4..dacdcd3 100644 --- a/src/client/whatsapp.ts +++ b/src/client/whatsapp.ts @@ -47,6 +47,12 @@ export class WhatsAppClient { this.socket.addEventListener("message", (data) => { const message = JSON.parse(data.data); + + if (message.type === "event") { + this.emit(message.event, message.data); + return; + } + if (message.type !== "response") { return; } diff --git a/ui/index.html b/ui/index.html index 2a79158..a746b63 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3,9 +3,12 @@ - + + + + - WhatsApp Bridge UI + WhatsApp Bridge diff --git a/ui/public/android-chrome-192x192.png b/ui/public/android-chrome-192x192.png new file mode 100644 index 0000000..d8e8ee0 Binary files /dev/null and b/ui/public/android-chrome-192x192.png differ diff --git a/ui/public/android-chrome-512x512.png b/ui/public/android-chrome-512x512.png new file mode 100644 index 0000000..d931646 Binary files /dev/null and b/ui/public/android-chrome-512x512.png differ diff --git a/ui/public/apple-touch-icon.png b/ui/public/apple-touch-icon.png new file mode 100644 index 0000000..1c3ed17 Binary files /dev/null and b/ui/public/apple-touch-icon.png differ diff --git a/ui/public/favicon-16x16.png b/ui/public/favicon-16x16.png new file mode 100644 index 0000000..8ff1fe1 Binary files /dev/null and b/ui/public/favicon-16x16.png differ diff --git a/ui/public/favicon-32x32.png b/ui/public/favicon-32x32.png new file mode 100644 index 0000000..623319c Binary files /dev/null and b/ui/public/favicon-32x32.png differ diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico new file mode 100644 index 0000000..9e992da Binary files /dev/null and b/ui/public/favicon.ico differ diff --git a/ui/public/site.webmanifest b/ui/public/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/ui/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/ui/src/app/chats.tsx b/ui/src/app/chats.tsx index a0b23a6..1ada2c4 100644 --- a/ui/src/app/chats.tsx +++ b/ui/src/app/chats.tsx @@ -1,9 +1,19 @@ import { useWAClient } from "./context"; import Avatar from "@/components/ui/avatar"; -import { getId } from "@/lib/utils"; -import type { ChatDTO } from "@shared/dto"; +import { useContacts } from "@/hooks/useContact"; +import { getFormattedName, getId } from "@/lib/utils"; +import type { ChatDTO, ContactDTO } from "@shared/dto"; import { useQuery } from "@tanstack/react-query"; -import { SearchIcon, XIcon } from "lucide-react"; +import dayjs from "dayjs"; +import { + FaceGrinningIcon, + ImageIcon, + PaperclipIcon, + PhoneIcon, + SearchIcon, + UsersIcon, + XIcon, +} from "lucide-react"; import { useMemo, useState } from "react"; type Props = { @@ -14,6 +24,7 @@ export default function ChatsSection({ selected, onSelect }: Props) { const [query, setQuery] = useState(""); const { client, isReady } = useWAClient(); + const contacts = useContacts(); const { data: chats } = useQuery({ queryKey: ["chats"], queryFn: () => @@ -25,17 +36,22 @@ export default function ChatsSection({ selected, onSelect }: Props) { }); const filteredChats = useMemo(() => { - if (!query.trim()) { - return chats?.slice(0, 100); - } + let items = chats || []; - return chats - ?.filter( + if (query.trim().length > 0) { + items = items.filter( (chat) => chat.formattedTitle?.toLowerCase().includes(query.toLowerCase()) || chat.lastMessage?.body?.toLowerCase().includes(query.toLowerCase()), - ) - ?.slice(0, 100); + ); + } + + return items.slice(0, 100).map((i) => ({ + ...i, + from: i.isGroup + ? contacts.findById(i.previewMessage?.author || null) + : null, + })); }, [chats, query]); return ( @@ -75,28 +91,32 @@ export default function ChatsSection({ selected, onSelect }: Props) { onClick={() => onSelect(chat)} className={`flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition cursor-pointer ${selected?.id === chat.id ? "bg-accent" : "hover:bg-muted/70"}`} > - + : null} + />

{chat.formattedTitle || chat.name}

- {/* - {chat.timestamp} - */} + + {dayjs((chat.timestamp || 0) * 1000).fromNow()} +
-
-

- {chat.previewMessage?.type === "chat" - ? chat.previewMessage?.body?.substring(0, 50) - : null} -

- {/* {chat.unread && ( - - {chat.unread} - - )} */} +
+ + + {/*
+ {chat.unreadCount ? ( + + {chat.unreadCount} + + ) : null} */}
@@ -105,3 +125,53 @@ export default function ChatsSection({ selected, onSelect }: Props) { ); } + +function ChatPreviewMessage({ + data, + from, +}: { + data: ChatDTO["previewMessage"]; + from?: ContactDTO | null; +}) { + if (!data) { + return null; + } + + let caption = useMemo(() => { + if (data.type === "image") { + return data.caption || "Picture"; + } + if (data.type === "document") { + return data.caption || "Document"; + } + if (data.type === "call_log") { + return "Call log"; + } + if (data.type === "sticker") { + return "Sticker"; + } + + return data.type === "chat" || data.caption + ? data.caption || data.body + : null; + }, [data]); + + return ( + <> + {from ? ( + + {getFormattedName(from) + ": "} + + ) : null} + + {data.type === "image" && } + {data.type === "document" && } + {data.type === "call_log" && } + {data.type === "sticker" && } + +

+ {caption?.substring(0, 50)} +

+ + ); +} diff --git a/ui/src/app/context.tsx b/ui/src/app/context.tsx index 23910db..e6c32fd 100644 --- a/ui/src/app/context.tsx +++ b/ui/src/app/context.tsx @@ -7,6 +7,7 @@ export const WAContext = createContext<{ client: WhatsAppClient; isConnected: boolean; isReady: boolean; + addListener: (event: string, callback: (...args: any[]) => any) => () => void; }>(null!); export function useWAClient() { @@ -15,8 +16,11 @@ export function useWAClient() { return ctx; } +type ListenerMap = Record any)[]>; + export function WAProvider({ children }: { children: React.ReactNode }) { const isInitialized = useRef(false); + const eventListeners = useRef({}); const [isConnected, setConnected] = useState(false); const { data: status } = useQuery({ @@ -25,6 +29,20 @@ export function WAProvider({ children }: { children: React.ReactNode }) { enabled: isConnected, }); + function addListener(event: string, callback: (...args: any[]) => any) { + eventListeners.current[event] = eventListeners.current[event] || []; + eventListeners.current[event].push(callback); + return () => { + eventListeners.current[event] = eventListeners.current[event].filter( + (i) => i !== callback, + ); + }; + } + + function emit(event: string, ...args: any[]) { + eventListeners.current[event]?.forEach((i) => i(...args)); + } + useEffect(() => { if (isInitialized.current) return; @@ -32,6 +50,7 @@ export function WAProvider({ children }: { children: React.ReactNode }) { isInitialized.current = true; wa.on("status", setConnected); + wa.on("message", (data) => emit("message", data)); }, []); return ( @@ -40,6 +59,7 @@ export function WAProvider({ children }: { children: React.ReactNode }) { client: wa, isConnected, isReady: status?.authenticated && status?.ready ? true : false, + addListener, }} > {children} diff --git a/ui/src/app/messages.tsx b/ui/src/app/messages.tsx index 000ff73..3b87198 100644 --- a/ui/src/app/messages.tsx +++ b/ui/src/app/messages.tsx @@ -5,6 +5,8 @@ import { useQuery } from "@tanstack/react-query"; import { cn } from "cn"; import { ArrowLeftIcon, + CheckCheckIcon, + CheckIcon, PaperclipIcon, PhoneCallIcon, SendIcon, @@ -14,6 +16,8 @@ import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import MessageMedia from "@/components/message-media"; import Avatar from "@/components/ui/avatar"; +import { useContacts } from "@/hooks/useContact"; +import { useMessageEvent } from "@/hooks/useMessageEvent"; dayjs.extend(relativeTime); @@ -32,11 +36,7 @@ export default function MessagesSection({ const [message, setMessage] = useState(""); const [isPending, setPending] = useState(false); - const { data: contacts } = useQuery({ - queryKey: ["contacts"], - queryFn: () => client.listContacts(), - refetchOnWindowFocus: false, - }); + const { data: contacts } = useContacts(); const chatId = getId(chat.id); const { data: messages, refetch } = useQuery({ @@ -46,17 +46,35 @@ export default function MessagesSection({ refetchInterval: 10_1000, }); + useMessageEvent((data) => { + if (data.chatId === chatId) refetch(); + }); + const messageList = useMemo(() => { return messages ?.filter((i) => !["revoked"].includes(i.type as never)) - ?.map((message) => { + ?.map((i) => { const from = contacts?.find( - (c) => getId(c.id) === getId(message.author || message.from), + (c) => getId(c.id) === getId(i.author || i.from), ); + + const quotedMsg = i.quotedMsg + ? { + ...i.quotedMsg, + from: contacts?.find((c) => { + return ( + getId(c.id) === + getId(i.quotedMsg?.author || i.quotedMsg?.from) + ); + }), + } + : null; + return { - ...message, - id: getId(message.id), + ...i, + id: getId(i.id), from, + quotedMsg, }; }) ?.reverse(); @@ -135,6 +153,7 @@ export default function MessagesSection({ {messageList?.map((item, index) => (
@@ -151,23 +170,48 @@ export default function MessagesSection({

) : null} - {item.quotedMsg && item.quotedMsg.type === "chat" ? ( - +

+ {item.quotedMsg.filename || + item.quotedMsg.caption || + (item.quotedMsg.type === "chat" + ? item.quotedMsg.body + : "Quoted")} +

+
) : null} {item.type === "image" || item.type === "sticker" || - item.type === "document" ? ( - + item.type === "document" || + item.type === "ptt" ? ( + ) : null} {item.type === "chat" || item.caption ? ( @@ -187,12 +231,19 @@ export default function MessagesSection({ className={`mt-1 flex items-center justify-end gap-1 text-[10px] ${item.fromMe ? "text-primary" : "text-muted-foreground"}`} > {dayjs((item.timestamp ?? 0) * 1000).fromNow()} - {/* {item.fromMe && - (item.read ? ( - - ) : ( - - ))} */} + + {item.fromMe && ( + <> + {item.ack && item.ack >= 2 ? ( + + ) : ( + + )} + + )}
@@ -232,7 +283,7 @@ export default function MessagesSection({ }} placeholder="Write a message..." rows={1} - className="max-h-24 min-h-10 flex-1 resize-none bg-transparent py-2.5 text-sm outline-none placeholder:text-muted-foreground" + className="max-h-24 min-h-10 flex-1 resize-none field-sizing-content bg-transparent py-2.5 text-sm outline-none placeholder:text-muted-foreground" /> ); diff --git a/ui/src/components/ui/avatar.tsx b/ui/src/components/ui/avatar.tsx index 23e06fc..33c209b 100644 --- a/ui/src/components/ui/avatar.tsx +++ b/ui/src/components/ui/avatar.tsx @@ -4,6 +4,7 @@ import { UserIcon } from "lucide-react"; type Props = { name?: string | null; + icon?: React.ReactNode; className?: string; iconClassName?: string; }; @@ -33,7 +34,12 @@ function getColorBasedOnName(name?: string | null) { return colorSchemes[getInitial(name).charCodeAt(0) % colorSchemes.length]; } -export default function Avatar({ name, className, iconClassName }: Props) { +export default function Avatar({ + name, + icon, + className, + iconClassName, +}: Props) { const color = getColorBasedOnName(name); return ( @@ -44,7 +50,9 @@ export default function Avatar({ name, className, iconClassName }: Props) { className, )} > - {name ? ( + {icon ? ( + icon + ) : name ? ( getInitial(name) ) : ( diff --git a/ui/src/globals.css b/ui/src/globals.css index ad155ed..04ca20e 100644 --- a/ui/src/globals.css +++ b/ui/src/globals.css @@ -144,3 +144,27 @@ font-size: 16px; } } + +@layer utilities { + .dark * { + scrollbar-width: thin; + scrollbar-color: #4b5563 transparent; + } + + .dark *::-webkit-scrollbar { + width: 6px; + } + + .dark *::-webkit-scrollbar-track { + background: transparent; + } + + .dark *::-webkit-scrollbar-thumb { + background: #4b5563; + border-radius: 9999px; + } + + .dark *::-webkit-scrollbar-thumb:hover { + background: #6b7280; + } +} \ No newline at end of file diff --git a/ui/src/hooks/useContact.ts b/ui/src/hooks/useContact.ts new file mode 100644 index 0000000..2dda8a4 --- /dev/null +++ b/ui/src/hooks/useContact.ts @@ -0,0 +1,23 @@ +import { useWAClient } from "@/app/context"; +import { getId } from "@/lib/utils"; +import type { ContactDTO } from "@shared/dto"; +import { useQuery } from "@tanstack/react-query"; + +export function useContacts() { + const { client, isReady } = useWAClient(); + + const res = useQuery({ + queryKey: ["contacts"], + queryFn: () => client.listContacts(), + refetchOnWindowFocus: false, + enabled: isReady, + }); + + return { + ...res, + findById(id: ContactDTO["id"]) { + if (!id) return null; + return res.data?.find((i) => getId(i.id) === getId(id)); + }, + }; +} diff --git a/ui/src/hooks/useMessageEvent.ts b/ui/src/hooks/useMessageEvent.ts new file mode 100644 index 0000000..e3ca365 --- /dev/null +++ b/ui/src/hooks/useMessageEvent.ts @@ -0,0 +1,10 @@ +import { useWAClient } from "@/app/context"; +import type { MessageDTO } from "@shared/dto"; +import { useEffect } from "react"; + +export function useMessageEvent(callback: (msg: MessageDTO) => any) { + const { addListener } = useWAClient(); + useEffect(() => { + return addListener("message", callback); + }, [callback]); +}