feat(comments) : show heart giver profiles on comments and notifications
This commit is contained in:
@@ -24,7 +24,7 @@ import {
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -377,6 +377,19 @@ export function CommentCard({
|
||||
likes: item.likes,
|
||||
hearted: item.hearted,
|
||||
};
|
||||
const heartedBy = item.heartedBy ?? (viewer?.admin && !item.hearted ? viewer : null);
|
||||
const heartLabel = `${heartedBy?.name || "ผู้ดูแล"} ถูกใจความคิดเห็นนี้`;
|
||||
const heartAvatar = (
|
||||
<SimpleTooltip text={heartLabel}>
|
||||
<Avatar size="sm" aria-label={heartLabel}>
|
||||
<AvatarImage src={heartedBy?.image ?? undefined} alt="" />
|
||||
<AvatarFallback>{heartedBy?.name?.slice(0, 2) || "ผู"}</AvatarFallback>
|
||||
<AvatarBadge className="-right-0.5 -bottom-0.5 bg-transparent text-destructive ring-0 group-data-[size=sm]/avatar:size-3.5 group-data-[size=sm]/avatar:[&>svg]:block [&>svg]:size-2.5!">
|
||||
<HeartIcon fill="currentColor" />
|
||||
</AvatarBadge>
|
||||
</Avatar>
|
||||
</SimpleTooltip>
|
||||
);
|
||||
const reactionQueue = useRef<Promise<void>>(Promise.resolve());
|
||||
const reactionSequence = useRef(0);
|
||||
const queuedReactions = useRef(0);
|
||||
@@ -759,35 +772,23 @@ export function CommentCard({
|
||||
)}
|
||||
{viewer?.admin ? (
|
||||
<Button
|
||||
variant={optimistic.hearted ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
aria-label={optimistic.hearted ? "ยกเลิกหัวใจจากผู้ดูแล" : "ให้หัวใจจากผู้ดูแล"}
|
||||
aria-pressed={optimistic.hearted}
|
||||
disabled={pending || !item.canInteract}
|
||||
onClick={() =>
|
||||
void mutate("heart", !desired.current.hearted)
|
||||
}
|
||||
>
|
||||
<HeartIcon
|
||||
data-icon="inline-start"
|
||||
className={cn(
|
||||
optimistic.hearted && "text-destructive",
|
||||
)}
|
||||
fill={optimistic.hearted ? "currentColor" : "none"}
|
||||
/>
|
||||
{optimistic.hearted ? "ผู้ดูแลถูกใจ" : null}
|
||||
{optimistic.hearted ? heartAvatar : <HeartIcon />}
|
||||
</Button>
|
||||
) : (
|
||||
optimistic.hearted &&
|
||||
!item.deleted &&
|
||||
!item.hidden && (
|
||||
<Badge variant="secondary">
|
||||
<HeartIcon
|
||||
data-icon="inline-start"
|
||||
className="text-destructive"
|
||||
fill="currentColor"
|
||||
/>
|
||||
{"ผู้ดูแลถูกใจ"}
|
||||
</Badge>
|
||||
heartAvatar
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CommentItem, CommentViewer } from "@/lib/comments/types";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
import { CommentCard } from "./comment-card";
|
||||
|
||||
const giver = { id: "admin", name: "Admin", image: "https://profile.example/admin.png" };
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let item: CommentItem;
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
const onChange = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.spyOn(HTMLImageElement.prototype, "complete", "get").mockReturnValue(true);
|
||||
vi.spyOn(HTMLImageElement.prototype, "naturalWidth", "get").mockReturnValue(64);
|
||||
fetchMock.mockReset().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
onChange.mockReset().mockResolvedValue(undefined);
|
||||
item = {
|
||||
id: "comment", rootId: null, replyToId: null, replyToName: null,
|
||||
authorName: "Reader", authorImage: null, authorAdmin: false, text: "Comment", images: [], version: 1,
|
||||
createdAt: "2026-10-08T12:00:00Z", editedAt: "2026-10-08T12:00:00Z",
|
||||
hidden: false, ownHidden: false, deleted: false, hearted: true, heartedBy: giver, likes: 0, reaction: 0,
|
||||
replyCount: 0, hasReplies: false, canEdit: false, canInteract: true, canModerate: false,
|
||||
target: "guide:test", targetName: "Test", targetImage: null, href: "/test/comment",
|
||||
};
|
||||
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
|
||||
});
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount()); container.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals();
|
||||
});
|
||||
async function mount(viewer: CommentViewer | null = null) {
|
||||
await act(async () => root.render(<CommentCard item={item} viewer={viewer} onChange={onChange} />));
|
||||
}
|
||||
function heartAvatar() { return container.querySelector('[data-slot="avatar"][aria-label="Admin ถูกใจความคิดเห็นนี้"]'); }
|
||||
|
||||
describe("admin profile hearts on comments", () => {
|
||||
it("shows the heart giver's profile and a small filled heart at its bottom right", async () => {
|
||||
await mount();
|
||||
const avatar = heartAvatar();
|
||||
expect(avatar?.querySelector("img")?.getAttribute("src")).toBe(giver.image);
|
||||
const badge = avatar?.querySelector('[data-slot="avatar-badge"]');
|
||||
expect(badge?.classList.contains("-right-0.5")).toBe(true);
|
||||
expect(badge?.classList.contains("-bottom-0.5")).toBe(true);
|
||||
expect(badge?.querySelector("svg")?.getAttribute("fill")).toBe("currentColor");
|
||||
expect(container.textContent).not.toContain("ผู้ดูแลถูกใจ");
|
||||
});
|
||||
|
||||
it("keeps the giver's initials and heart when the profile picture is unavailable", async () => {
|
||||
item.heartedBy = { ...giver, image: null };
|
||||
await mount();
|
||||
expect(heartAvatar()?.querySelector('[data-slot="avatar-fallback"]')?.textContent).toBe("Ad");
|
||||
expect(heartAvatar()?.querySelector('[data-slot="avatar-badge"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each(["not hearted", "hidden", "deleted"])("hides the public profile heart when the comment is %s", async (state) => {
|
||||
if (state === "not hearted") item.hearted = false;
|
||||
else if (state === "hidden") item.hidden = true;
|
||||
else item.deleted = true;
|
||||
await mount(); expect(heartAvatar()).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves the admin action to remove a heart", async () => {
|
||||
await mount({ ...giver, admin: true });
|
||||
const button = container.querySelector<HTMLButtonElement>('button[aria-label="ยกเลิกหัวใจจากผู้ดูแล"]');
|
||||
expect(button?.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(button?.querySelector('[data-slot="avatar-badge"]')).not.toBeNull();
|
||||
await act(async () => button!.querySelector<HTMLElement>('[data-slot="avatar"]')!.click());
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/comments/comment/heart", expect.objectContaining({ method: "PUT", body: '{"hearted":false}' }));
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
expect(heartAvatar()).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the acting admin's profile immediately when adding a heart", async () => {
|
||||
item.hearted = false; item.heartedBy = null;
|
||||
await mount({ ...giver, admin: true });
|
||||
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="ให้หัวใจจากผู้ดูแล"]')!.click());
|
||||
expect(heartAvatar()?.querySelector("img")?.getAttribute("src")).toBe(giver.image);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/comments/comment/heart", expect.objectContaining({ body: '{"hearted":true}' }));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user