multica/apps/web/features/issues/components/comment-card.tsx
Jiayuan 7c1aabbe3a feat(reactions): add emoji reactions for comments and issue descriptions
Add Slack-style emoji reactions to comments and issue descriptions with
full-stack support: database tables, REST API endpoints, real-time
WebSocket sync, optimistic UI updates, and inbox notifications.

- New `comment_reaction` and `issue_reaction` tables with migrations
- POST/DELETE endpoints for adding/removing reactions on both comments
  and issue descriptions
- Real-time WS events (reaction:added/removed, issue_reaction:added/removed)
- Shared ReactionBar component with quick emoji picker and full emoji-mart
  picker (lazy-loaded)
- Optimistic add/remove with rollback on failure
- Inbox notifications for comment author and issue creator when reacted to
- Reactions included in timeline, comment list, and issue detail responses
2026-03-30 22:37:59 +08:00

227 lines
7.1 KiB
TypeScript

"use client";
import { useState } from "react";
import { MoreHorizontal } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { ReactionBar } from "@/components/common/reaction-bar";
import { Markdown } from "@/components/markdown";
import { useActorName } from "@/features/workspace";
import { timeAgo } from "@/shared/utils";
import { ReplyInput } from "./reply-input";
import type { TimelineEntry } from "@/shared/types";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface CommentCardProps {
entry: TimelineEntry;
allReplies: Map<string, TimelineEntry[]>;
currentUserId?: string;
onReply: (parentId: string, content: string) => Promise<void>;
onEdit: (commentId: string, content: string) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
}
// ---------------------------------------------------------------------------
// Single comment row (used for both parent and replies within the same Card)
// ---------------------------------------------------------------------------
function CommentRow({
entry,
currentUserId,
onEdit,
onDelete,
onToggleReaction,
}: {
entry: TimelineEntry;
currentUserId?: string;
onEdit: (commentId: string, content: string) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
}) {
const { getActorName } = useActorName();
const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState("");
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const isTemp = entry.id.startsWith("temp-");
const startEdit = () => {
setEditContent(entry.content ?? "");
setEditing(true);
};
const cancelEdit = () => {
setEditing(false);
setEditContent("");
};
const saveEdit = async () => {
const trimmed = editContent.trim();
if (!trimmed) return;
try {
await onEdit(entry.id, trimmed);
setEditing(false);
setEditContent("");
} catch {
toast.error("Failed to update comment");
}
};
const reactions = entry.reactions ?? [];
return (
<div className={`py-3${isTemp ? " opacity-60" : ""}`}>
<div className="flex items-center gap-2.5">
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} />
<span className="text-sm font-medium">
{getActorName(entry.actor_type, entry.actor_id)}
</span>
<Tooltip>
<TooltipTrigger
render={
<span className="text-xs text-muted-foreground cursor-default">
{timeAgo(entry.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(entry.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
{!isTemp && isOwn && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-xs" className="ml-auto text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={startEdit}>Edit</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(entry.id)} variant="destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{editing ? (
<form
onSubmit={(e) => { e.preventDefault(); saveEdit(); }}
className="mt-2 pl-8"
>
<input
autoFocus
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
aria-label="Edit comment"
className="w-full text-sm bg-transparent border-b border-border outline-none py-1"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
/>
<div className="flex gap-2 mt-1.5">
<Button size="sm" type="submit">Save</Button>
<Button size="sm" variant="ghost" type="button" onClick={cancelEdit}>Cancel</Button>
</div>
</form>
) : (
<>
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
<Markdown mode="minimal">{entry.content ?? ""}</Markdown>
</div>
{!isTemp && (
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
className="mt-1.5 pl-8"
/>
)}
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// CommentCard — One Card per thread (parent + all replies flat inside)
// ---------------------------------------------------------------------------
function CommentCard({
entry,
allReplies,
currentUserId,
onReply,
onEdit,
onDelete,
onToggleReaction,
}: CommentCardProps) {
// Collect all nested replies recursively into a flat list
const allNestedReplies: TimelineEntry[] = [];
const collectReplies = (parentId: string) => {
const children = allReplies.get(parentId) ?? [];
for (const child of children) {
allNestedReplies.push(child);
collectReplies(child.id);
}
};
collectReplies(entry.id);
return (
<Card className={`!py-0 !gap-0 overflow-hidden${entry.id.startsWith("temp-") ? " opacity-60" : ""}`}>
{/* Parent comment */}
<div className="px-4">
<CommentRow
entry={entry}
currentUserId={currentUserId}
onEdit={onEdit}
onDelete={onDelete}
onToggleReaction={onToggleReaction}
/>
</div>
{/* Replies — flat, separated by border */}
{allNestedReplies.map((reply) => (
<div key={reply.id} className="border-t border-border/50 px-4">
<CommentRow
entry={reply}
currentUserId={currentUserId}
onEdit={onEdit}
onDelete={onDelete}
onToggleReaction={onToggleReaction}
/>
</div>
))}
{/* Reply input — always visible at bottom */}
<div className="border-t border-border/50 px-4 py-2.5">
<ReplyInput
placeholder="Leave a reply..."
size="sm"
avatarType="member"
avatarId={currentUserId ?? ""}
onSubmit={(content) => onReply(entry.id, content)}
/>
</div>
</Card>
);
}
export { CommentCard, type CommentCardProps };