diff --git a/src/userscript/index.ts b/src/userscript/index.ts index 20df192..d62397d 100644 --- a/src/userscript/index.ts +++ b/src/userscript/index.ts @@ -48,7 +48,10 @@ function error(id: string, err: unknown) { ok: false, error: { code: "ERROR", - message: err instanceof Error ? err.message : String(err), + message: + typeof err === "object" && "message" in err! + ? err.message + : String(err), }, }); } diff --git a/ui/src/app/chats.tsx b/ui/src/app/chats.tsx index 1ada2c4..0b3cf01 100644 --- a/ui/src/app/chats.tsx +++ b/ui/src/app/chats.tsx @@ -1,9 +1,8 @@ -import { useWAClient } from "./context"; import Avatar from "@/components/ui/avatar"; +import { useChats } from "@/hooks/useChats"; import { useContacts } from "@/hooks/useContact"; -import { getFormattedName, getId } from "@/lib/utils"; +import { getFormattedName } from "@/lib/utils"; import type { ChatDTO, ContactDTO } from "@shared/dto"; -import { useQuery } from "@tanstack/react-query"; import dayjs from "dayjs"; import { FaceGrinningIcon, @@ -17,21 +16,14 @@ import { import { useMemo, useState } from "react"; type Props = { - selected: ChatDTO | null; - onSelect: (chat: ChatDTO) => void; + selected: string | null; + onSelect: (chat: string) => void; }; 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: () => - client - .listChats({ limit: 500 }) - .then((res) => res.map((i) => ({ ...i, id: getId(i.id) }))), - enabled: isReady, + const { data: chats } = useChats({ refetchInterval: 10_000, }); @@ -88,8 +80,8 @@ export default function ChatsSection({ selected, onSelect }: Props) { {filteredChats?.map((chat) => ( - +

- {chat.formattedTitle || chat.name} + {chat?.formattedTitle || chat?.name}

{/*

@@ -153,7 +197,7 @@ export default function MessagesSection({ {messageList?.map((item, index) => (

@@ -178,13 +222,14 @@ export default function MessagesSection({ ["chat", "image", "document"].includes( item.quotedMsg.type as string, ) ? ( - { - e.preventDefault(); - const el = document.getElementById( - getId(item.quotedMsgId), + ) : null} {item.type === "image" || @@ -215,9 +263,7 @@ export default function MessagesSection({ ) : null} {item.type === "chat" || item.caption ? ( -

- {item.caption || item.body} -

+ ) : null} {item.type === "call_log" && ( @@ -249,6 +295,24 @@ export default function MessagesSection({
))} + +
+ +
+ +
+ {isLoading || + (isFetchingNextPage && ( + + ))} + + {isLoading || isFetchingNextPage + ? "Loading..." + : "End of messages"} + +
+ +
diff --git a/ui/src/app/page.tsx b/ui/src/app/page.tsx index 6255174..0011784 100644 --- a/ui/src/app/page.tsx +++ b/ui/src/app/page.tsx @@ -1,25 +1,19 @@ import ChatsSection from "./chats"; -import type { ChatDTO } from "@shared/dto"; -import MessagesSection from "./messages"; import DevicesSection from "./devices"; import { useLocalStorage } from "@/hooks/useLocalStorage"; +import { useHashRouter } from "@/hooks/useHashRouter"; +import MessagesSection from "./messages"; export default function Page() { - const [chat, setChat] = useLocalStorage( - "app/current-chat", - null, - ); - const [mobileChat, setMobileChat] = useLocalStorage( - "app/mobile-chat", - false, - ); + const router = useHashRouter(); const [curDeviceId, setDeviceId] = useLocalStorage("app/device", ""); + const chatId = router.query.get("chatId"); return (
{/*
*/} - {chat ? ( - - ) : null} + {chatId ? : null} {/*
*/}
); diff --git a/ui/src/components/message-media.tsx b/ui/src/components/message-media.tsx index ce749c7..716d0ca 100644 --- a/ui/src/components/message-media.tsx +++ b/ui/src/components/message-media.tsx @@ -2,7 +2,12 @@ import { useWAClient } from "@/app/context"; import { storage } from "@/lib/storage"; import type { MessageDTO } from "@shared/dto"; import { cn } from "cn"; -import { DownloadIcon, Loader2Icon, PaperclipIcon } from "lucide-react"; +import { + DownloadIcon, + ImageIcon, + Loader2Icon, + PaperclipIcon, +} from "lucide-react"; import { useEffect, useRef, useState } from "react"; type Props = { @@ -17,7 +22,9 @@ export default function MessageMedia({ data }: Props) { const [isLoading, setLoading] = useState(false); const [isDownloaded, setDownloaded] = useState(false); const [body, setBody] = useState( - type === "image" ? "data:image/webp;base64," + data.body : data.body, + type === "image" && data.body + ? "data:image/webp;base64," + data.body + : data.body, ); useEffect(() => { @@ -85,17 +92,28 @@ export default function MessageMedia({ data }: Props) { return ( openMedia()} - className="w-full h-32 aspect-video block relative overflow-hidden" + onClick={(e) => { + e.preventDefault(); + openMedia(); + }} + className={cn( + "w-full min-h-32 max-h-40 aspect-video block relative overflow-hidden rounded", + )} > - + {body ? ( + + ) : ( +
+ +
+ )} {isLoading && (
diff --git a/ui/src/components/paragraph.tsx b/ui/src/components/paragraph.tsx new file mode 100644 index 0000000..ecea727 --- /dev/null +++ b/ui/src/components/paragraph.tsx @@ -0,0 +1,132 @@ +import { cn } from "cn"; +import React from "react"; + +type Token = + | { type: "text"; value: string } + | { type: "bold"; value: string } + | { type: "italic"; value: string } + | { type: "strike"; value: string } + | { type: "code"; value: string } + | { type: "url"; value: string }; + +function renderWhatsAppMessage(message: string): React.ReactNode { + const regex = + /(\*[^*\n]+\*|_[^_\n]+_|~[^~\n]+~|`[^`\n]+`|https?:\/\/[^\s<]+|www\.[^\s<]+)/g; + + const tokens: Token[] = []; + let lastIndex = 0; + + for (const match of message.matchAll(regex)) { + const index = match.index ?? 0; + const value = match[0]; + + if (index > lastIndex) { + tokens.push({ + type: "text", + value: message.slice(lastIndex, index), + }); + } + + if (value.startsWith("*")) { + tokens.push({ type: "bold", value: value.slice(1, -1) }); + } else if (value.startsWith("_")) { + tokens.push({ type: "italic", value: value.slice(1, -1) }); + } else if (value.startsWith("~")) { + tokens.push({ type: "strike", value: value.slice(1, -1) }); + } else if (value.startsWith("`")) { + tokens.push({ type: "code", value: value.slice(1, -1) }); + } else { + // Remove punctuation that commonly follows a URL. + const url = value.replace(/[.,!?;:)]+$/, ""); + + tokens.push({ type: "url", value: url }); + + // Preserve trailing punctuation. + if (url.length < value.length) { + tokens.push({ + type: "text", + value: value.slice(url.length), + }); + } + } + + lastIndex = index + value.length; + } + + if (lastIndex < message.length) { + tokens.push({ + type: "text", + value: message.slice(lastIndex), + }); + } + + return tokens.map((token, i) => { + switch (token.type) { + case "bold": + return ( + + {token.value} + + ); + + case "italic": + return ( + + {token.value} + + ); + + case "strike": + return ( + + {token.value} + + ); + + case "code": + return ( + + {token.value} + + ); + + case "url": + return ( + + {token.value} + + ); + + case "text": + return {token.value}; + } + }); +} + +type Props = { + text?: string | null; + className?: string; +}; + +export default function Paragraph({ text, className }: Props) { + if (!text) return null; + + return ( +
+ {renderWhatsAppMessage(text)} +
+ ); +} diff --git a/ui/src/hooks/useChats.ts b/ui/src/hooks/useChats.ts new file mode 100644 index 0000000..8965b92 --- /dev/null +++ b/ui/src/hooks/useChats.ts @@ -0,0 +1,22 @@ +import { useWAClient } from "@/app/context"; +import { getId } from "@/lib/utils"; +import type { WhatsAppClient } from "@client/whatsapp"; +import { useQuery, type UseQueryOptions } from "@tanstack/react-query"; + +async function getChats(client: WhatsAppClient) { + return client + .listChats({ limit: 500 }) + .then((res) => res.map((i) => ({ ...i, id: getId(i.id) }))); +} + +type Response = Awaited>; + +export function useChats(opt?: Partial) { + const { client, isReady } = useWAClient(); + return useQuery({ + queryKey: ["chats"], + queryFn: () => getChats(client), + enabled: isReady, + ...((opt || {}) as any), + }); +} diff --git a/ui/src/hooks/useHashRouter.ts b/ui/src/hooks/useHashRouter.ts new file mode 100644 index 0000000..dd8f44e --- /dev/null +++ b/ui/src/hooks/useHashRouter.ts @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useState } from "react"; + +type HashLocation = { + path: string; + query: URLSearchParams; + search: string; +}; + +type HashRouter = HashLocation & { + navigate: (path: string, options?: NavigateOptions) => void; + push: (path: string) => void; + reset: (path?: string) => void; + canGoback: () => boolean; + goBack: () => void; +}; + +type NavigateOptions = { + replace?: boolean; +}; + +function getHashLocation(): HashLocation { + const hash = window.location.hash.slice(1); + + const [pathname, search = ""] = hash.split("?"); + + const path = pathname + ? pathname.startsWith("/") + ? pathname + : `/${pathname}` + : "/"; + + return { + path, + search: search ? `?${search}` : "", + query: new URLSearchParams(search), + }; +} + +function normalizePath(path: string) { + return path.startsWith("/") ? path : `/${path}`; +} + +function setHashPath(path: string, replace = false) { + const normalized = normalizePath(path); + const hash = `#${normalized}`; + + if (replace) { + window.history.replaceState(null, "", hash); + + // replaceState doesn't emit hashchange + window.dispatchEvent(new HashChangeEvent("hashchange")); + } else { + window.location.hash = normalized; + } +} + +export function useHashRouter(): HashRouter { + const [location, setLocation] = useState(getHashLocation); + + useEffect(() => { + const handleChange = () => { + setLocation(getHashLocation()); + }; + + window.addEventListener("hashchange", handleChange); + + return () => { + window.removeEventListener("hashchange", handleChange); + }; + }, []); + + const navigate = useCallback( + (path: string, options: NavigateOptions = {}) => { + setHashPath(path, options.replace); + }, + [], + ); + + const push = useCallback( + (path: string) => { + navigate(path); + }, + [navigate], + ); + + const reset = useCallback( + (path = "/") => { + navigate(path, { replace: true }); + }, + [navigate], + ); + + const canGoback = useCallback(() => window.history.length > 1, []); + + const goBack = useCallback(() => { + if (canGoback()) { + window.history.back(); + } else { + reset(); + } + }, [canGoback, reset]); + + return { + ...location, + navigate, + push, + reset, + canGoback, + goBack, + }; +} diff --git a/ui/src/hooks/useIntersection.ts b/ui/src/hooks/useIntersection.ts new file mode 100644 index 0000000..a524012 --- /dev/null +++ b/ui/src/hooks/useIntersection.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef } from "react"; + +export function useIntersection( + callback: (entry: IntersectionObserverEntry) => void, + options: any = {}, +) { + const ref = useRef(null); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const observer = new IntersectionObserver(([entry]) => { + if (entry.isIntersecting) { + callback(entry); + } + }, options); + + observer.observe(element); + + return () => observer.disconnect(); + }, [callback, options]); + + return ref; +}