feat: add forward msg

This commit is contained in:
2026-09-20 09:29:07 +08:00
parent c2af4f9f6b
commit c9c55f56c8
10 changed files with 990 additions and 61 deletions
+100
View File
@@ -0,0 +1,100 @@
import { forwardDlg } from "@/app/stores";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "./ui/button";
import { useChats } from "@/hooks/useChats";
import Select, { type Option } from "@/components/ui/react-select";
import { useEffect, useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useWAClient } from "@/app/context";
import { Loader2Icon } from "lucide-react";
export default function ForwardDialog() {
const { data, isOpen } = forwardDlg.useStore();
const { client } = useWAClient();
const { data: chats } = useChats();
const [selectedChat, setSelectedChat] = useState<Option[]>([]);
const fwd = useMutation({
mutationFn: async (payload: { chatIds: string[]; msgIds: string[] }) => {
const { chatIds, msgIds } = payload;
for (const chatId of chatIds) {
await client.exec(
`return WPP.chat.forwardMessages('${chatId}', ${JSON.stringify(msgIds)});`,
);
}
},
onSuccess() {
alert("Message forwarded!");
forwardDlg.onClose();
},
onError(err) {
alert(err.message);
},
});
useEffect(() => {
if (isOpen) setSelectedChat([]);
}, [isOpen]);
const chatOptions = useMemo(() => {
return (chats || []).map((chat) => ({
value: chat.id,
label: chat.formattedTitle || chat.name,
}));
}, [chats]);
function onSubmit() {
if (
fwd.isPending ||
!data?.messageIds?.length ||
!window.confirm("Send message?")
)
return;
fwd.mutate({
chatIds: selectedChat.map((i) => i.value),
msgIds: data!.messageIds,
});
}
return (
<Dialog open={isOpen} onOpenChange={forwardDlg.onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Forward Messages</DialogTitle>
<DialogDescription>
{data?.messageIds.length} messages
</DialogDescription>
</DialogHeader>
<div>
<Select
options={chatOptions}
isMulti
value={selectedChat}
onChange={(i) => setSelectedChat(i as never)}
/>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline">Cancel</Button>} />
<Button
onClick={onSubmit}
disabled={!selectedChat.length || fwd.isPending}
>
{fwd.isPending && <Loader2Icon className="mr-2 animate-spin" />}
Send Message
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+154
View File
@@ -0,0 +1,154 @@
import * as React from "react";
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
import { cn } from "cn";
import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react";
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
/>
}
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("leading-none font-medium", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className,
)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
+251
View File
@@ -0,0 +1,251 @@
import BaseSelect, {
components,
type ControlProps,
type MultiValueProps,
type OptionProps,
type SingleValueProps,
type StylesConfig,
} from "react-select";
export type Option = {
value: string;
label: string;
};
// ─────────────────────────────────────────────
// Custom Control
// ─────────────────────────────────────────────
function CustomControl(props: ControlProps<Option, true>) {
return <components.Control {...props}>{props.children}</components.Control>;
}
// ─────────────────────────────────────────────
// Custom Option
// ─────────────────────────────────────────────
function CustomOption(props: OptionProps<Option, true>) {
return (
<components.Option {...props}>
<div className="flex items-center justify-between">
<span>{props.data.label}</span>
{props.isSelected && <span className="text-zinc-300">✓</span>}
</div>
</components.Option>
);
}
// ─────────────────────────────────────────────
// Custom Single Value
// ─────────────────────────────────────────────
function CustomSingleValue(props: SingleValueProps<Option>) {
return (
<components.SingleValue {...props}>
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-indigo-400" />
{props.data.label}
</div>
</components.SingleValue>
);
}
// ─────────────────────────────────────────────
// Custom Multi Value
// ─────────────────────────────────────────────
function CustomMultiValue(props: MultiValueProps<Option, true>) {
return (
<components.MultiValue {...props}>
<span>{props.data.label}</span>
</components.MultiValue>
);
}
// ─────────────────────────────────────────────
// Shadcn-inspired dark styles
// ─────────────────────────────────────────────
const darkStyles: StylesConfig<Option, true> = {
control: (base, state) => ({
...base,
minHeight: 40,
backgroundColor: "#09090b",
borderColor: state.isFocused ? "#71717a" : "#27272a",
borderRadius: 6,
boxShadow: state.isFocused ? "0 0 0 1px #71717a" : "none",
cursor: "pointer",
"&:hover": {
borderColor: "#3f3f46",
},
}),
valueContainer: (base) => ({
...base,
padding: "3px 8px",
gap: 4,
}),
input: (base) => ({
...base,
color: "#fafafa",
margin: 0,
padding: 0,
}),
placeholder: (base) => ({
...base,
color: "#71717a",
}),
singleValue: (base) => ({
...base,
color: "#fafafa",
}),
// ───────────────────────────────────────────
// Menu
// ───────────────────────────────────────────
menu: (base) => ({
...base,
marginTop: 4,
backgroundColor: "#09090b",
border: "1px solid #27272a",
borderRadius: 6,
overflow: "hidden",
boxShadow:
"0 10px 15px -3px rgb(0 0 0 / 0.3), " + "0 4px 6px -4px rgb(0 0 0 / 0.3)",
}),
menuList: (base) => ({
...base,
padding: 4,
}),
// ───────────────────────────────────────────
// Options
// ───────────────────────────────────────────
option: (base, state) => ({
...base,
borderRadius: 4,
padding: "7px 8px",
backgroundColor: state.isSelected
? "#27272a"
: state.isFocused
? "#18181b"
: "transparent",
color: state.isDisabled ? "#52525b" : "#fafafa",
cursor: state.isDisabled ? "not-allowed" : "pointer",
"&:active": {
backgroundColor: "#27272a",
},
}),
// ───────────────────────────────────────────
// Multi value
// ───────────────────────────────────────────
multiValue: (base) => ({
...base,
backgroundColor: "#27272a",
borderRadius: 4,
}),
multiValueLabel: (base) => ({
...base,
color: "#e4e4e7",
padding: "2px 6px",
}),
multiValueRemove: (base) => ({
...base,
color: "#a1a1aa",
borderRadius: "0 4px 4px 0",
"&:hover": {
backgroundColor: "#3f3f46",
color: "#fafafa",
},
}),
// ───────────────────────────────────────────
// Indicators
// ───────────────────────────────────────────
indicatorSeparator: () => ({
display: "none",
}),
dropdownIndicator: (base) => ({
...base,
color: "#71717a",
padding: "0 8px",
"&:hover": {
color: "#a1a1aa",
},
}),
clearIndicator: (base) => ({
...base,
color: "#71717a",
"&:hover": {
color: "#fafafa",
},
}),
// ───────────────────────────────────────────
// Messages
// ───────────────────────────────────────────
noOptionsMessage: (base) => ({
...base,
color: "#71717a",
}),
loadingMessage: (base) => ({
...base,
color: "#71717a",
}),
};
// ─────────────────────────────────────────────
// Select
// ─────────────────────────────────────────────
type Props = React.ComponentProps<typeof BaseSelect>;
function Select({ ...props }: Props) {
return (
<BaseSelect
styles={darkStyles}
components={{
Control: CustomControl,
Option: CustomOption,
SingleValue: CustomSingleValue,
MultiValue: CustomMultiValue,
}}
{...(props as any)}
/>
);
}
export default Select;