feat: add remove message feature
This commit is contained in:
+67
-10
@@ -6,10 +6,12 @@ import {
|
|||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
CheckCheckIcon,
|
CheckCheckIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
ChevronDown,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
PhoneCallIcon,
|
PhoneCallIcon,
|
||||||
SendIcon,
|
SendIcon,
|
||||||
|
Trash2Icon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -26,6 +28,13 @@ import Paragraph from "@/components/paragraph";
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import SearchInput from "@/components/search-input";
|
import SearchInput from "@/components/search-input";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
@@ -34,20 +43,17 @@ type Props = {
|
|||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
|
export default function MessagesSection({ chatId, maxCount = 20 }: Props) {
|
||||||
const { client, isReady } = useWAClient();
|
const { client, isReady } = useWAClient();
|
||||||
const router = useHashRouter();
|
const router = useHashRouter();
|
||||||
const msgContainerRef = useRef<HTMLDivElement>(null!);
|
const msgContainerRef = useRef<HTMLDivElement>(null!);
|
||||||
|
const lastFetch = useRef<number>(0);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: chats } = useChats();
|
const { data: chats } = useChats();
|
||||||
const { data: contacts } = useContacts();
|
const { data: contacts } = useContacts();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (chatId) msgContainerRef.current.scrollTop = 0;
|
|
||||||
}, [chatId]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: messages,
|
data: messages,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -76,6 +82,10 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
|
|||||||
select: (data) => data.pages.flat(),
|
select: (data) => data.pages.flat(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (chatId) msgContainerRef.current.scrollTop = 0;
|
||||||
|
}, [chatId]);
|
||||||
|
|
||||||
useMessageEvent((data) => {
|
useMessageEvent((data) => {
|
||||||
if (data.chatId !== chatId) return;
|
if (data.chatId !== chatId) return;
|
||||||
|
|
||||||
@@ -90,9 +100,15 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const endScrollRef = useIntersection(() => {
|
const endScrollRef = useIntersection(() => {
|
||||||
if (hasNextPage && !isFetchingNextPage && !isLoading) {
|
if (!hasNextPage || isFetchingNextPage || isLoading) return;
|
||||||
fetchNextPage();
|
|
||||||
}
|
const t = Date.now() - lastFetch.current;
|
||||||
|
if (t < 1000) return;
|
||||||
|
|
||||||
|
fetchNextPage().then(() => {
|
||||||
|
lastFetch.current = Date.now();
|
||||||
|
msgContainerRef.current.scrollTop -= 10;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const chat = useMemo(
|
const chat = useMemo(
|
||||||
@@ -147,6 +163,18 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
|
|||||||
});
|
});
|
||||||
}, [messages, contacts, search]);
|
}, [messages, contacts, search]);
|
||||||
|
|
||||||
|
async function onDeleteMsg(id: string) {
|
||||||
|
if (!window.confirm("Delete this message?")) return;
|
||||||
|
try {
|
||||||
|
await client.exec(
|
||||||
|
`return WPP.chat.deleteMessage('${chatId}', '${id}', true, true);`,
|
||||||
|
);
|
||||||
|
refetch();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getMentionLabel(username: string) {
|
function getMentionLabel(username: string) {
|
||||||
const data = contacts?.find((c) => (c.id as any).user === username);
|
const data = contacts?.find((c) => (c.id as any).user === username);
|
||||||
return data?.notifyName || data?.formattedName || data?.name || username;
|
return data?.notifyName || data?.formattedName || data?.name || username;
|
||||||
@@ -232,7 +260,10 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
|
|||||||
<div
|
<div
|
||||||
id={item.id.replace(/^(?:false_|true_)/, "")}
|
id={item.id.replace(/^(?:false_|true_)/, "")}
|
||||||
key={`${item.timestamp}-${index}`}
|
key={`${item.timestamp}-${index}`}
|
||||||
className={`mb-3 flex ${item.fromMe ? "justify-end" : "justify-start"}`}
|
className={cn(
|
||||||
|
"mb-3 flex",
|
||||||
|
item.fromMe ? "justify-end" : "justify-start",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="max-w-[82%] sm:max-w-[65%]">
|
<div className="max-w-[82%] sm:max-w-[65%]">
|
||||||
<a
|
<a
|
||||||
@@ -248,8 +279,33 @@ export default function MessagesSection({ chatId, maxCount = 50 }: Props) {
|
|||||||
{getFormattedName(item.from) || item.notifyName}
|
{getFormattedName(item.from) || item.notifyName}
|
||||||
</a>
|
</a>
|
||||||
<div
|
<div
|
||||||
className={`rounded-2xl px-4 py-2.5 text-sm shadow-sm ${item.fromMe ? "rounded-br-md bg-[#d8f5e8] dark:bg-[#26855c] text-foreground" : "rounded-bl-md bg-card text-card-foreground"}`}
|
className={cn(
|
||||||
|
"rounded-2xl px-4 py-2.5 text-sm shadow-sm relative group",
|
||||||
|
item.fromMe
|
||||||
|
? "rounded-br-md bg-[#d8f5e8] dark:bg-[#26855c] text-foreground"
|
||||||
|
: "rounded-bl-md bg-card text-card-foreground",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="icon-sm"
|
||||||
|
className="absolute top-1 right-1 z-1 rounded-full opacity-0 group-hover:opacity-100"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChevronDown />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onDeleteMsg(item.id)}>
|
||||||
|
<Trash2Icon />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
{item.isForwarded ? (
|
{item.isForwarded ? (
|
||||||
<p className="text-xs italic mb-1 text-primary/80">
|
<p className="text-xs italic mb-1 text-primary/80">
|
||||||
Forwarded
|
Forwarded
|
||||||
@@ -522,6 +578,7 @@ function MessageInputSection({
|
|||||||
value={message}
|
value={message}
|
||||||
onChange={(event) => setMessage(event.target.value)}
|
onChange={(event) => setMessage(event.target.value)}
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
|
enterKeyHint="enter"
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (
|
if (
|
||||||
event.key === "Enter" &&
|
event.key === "Enter" &&
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
||||||
|
import { cn } from "cn";
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react";
|
||||||
|
|
||||||
|
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||||
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
MenuPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-md bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||||
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.GroupLabel.Props & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.GroupLabel
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-2 py-1.5 text-xs font-medium text-muted-foreground data-inset:pl-8",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Item.Props & {
|
||||||
|
inset?: boolean;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||||
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-8 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -3,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn(
|
||||||
|
"w-auto min-w-[96px] rounded-md bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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,
|
||||||
|
)}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon />
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon />
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user