Files
multica/packages/views/issues/components/comment-card.tsx
Naiyuan Qing 454c8e3d1a feat: in-app preview for non-image attachments (#2528)
* feat(storage): add GetReader to Storage interface

Adds a streaming read method to the Storage abstraction so callers can
pull object bytes without forcing a full in-memory load. S3Storage wraps
GetObject; LocalStorage opens the file with path-traversal and sidecar
guards. Tests cover happy path, traversal rejection, sidecar rejection,
and missing key.

Used in the next commit by the attachment-preview proxy endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server): add attachment preview proxy endpoint

GET /api/attachments/{id}/content streams the raw bytes of a
text-previewable attachment back to the client. Exists to (a) bypass
CloudFront CORS, which is not configured on the CDN, and (b) bypass
Content-Disposition: attachment which Chromium honors for iframe document
loads. Media types (image/video/audio/pdf) intentionally do NOT go through
this endpoint — clients render them directly from the signed CloudFront
download_url, which is already served with Content-Disposition: inline.

Hard cap: 2 MB. Larger files return 413. Anything outside the text
whitelist returns 415. The whitelist (isTextPreviewable) mirrors the
client-side dispatcher; the cross-reference comment in file.go flags
the manual sync until a JSON SSOT generator lands.

Response always uses Content-Type: text/plain; charset=utf-8 so a
hostile HTML payload can't be re-interpreted as a document. The
original MIME ships via X-Original-Content-Type for client dispatch.
Cache-Control: no-store so revoked attachment access takes effect
immediately on the next request.

Tests cover happy path (md), extension fallback when content_type is
generic, 415 (pdf), 413 (>2MB), foreign workspace (404 isolation), and
the isTextPreviewable table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(core/api): add getAttachmentTextContent + preview error types

Adds an ApiClient method that fetches the text body of an attachment via
the new /api/attachments/{id}/content proxy. Two typed errors —
PreviewTooLargeError (413) and PreviewUnsupportedError (415) — let the
preview modal render specific fallbacks instead of a generic failure.

Refactors the private fetch() into a shared fetchRaw() helper so the
new method inherits the standard infra: auth headers, 401 →
handleUnauthorized recovery, X-Request-ID, error logging, and the
ApiError contract. The previous draft bypassed all of these by calling
window.fetch directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(views/editor): add AttachmentPreviewModal + Eye entry points

In-app preview for non-image attachments. An Eye icon now sits next to
the existing Download button on file cards / readonly file cards / the
standalone AttachmentList. Clicking it opens a full-screen modal that
dispatches by content_type:

  pdf:      <iframe src={download_url}>           — Chromium PDFium
  video/*:  <video controls src={download_url}>   — native controls
  audio/*:  <audio controls src={download_url}>   — native controls
  md:       <ReadonlyContent>                     — full markdown pipeline
  html:     <iframe srcdoc sandbox="">            — fully restricted
  text:     <code class="hljs">                   — lowlight highlight

Media types render directly from the signed CloudFront download_url
(server marks them inline-disposition). Text types fetch through the
new /api/attachments/{id}/content proxy via TanStack Query, wrapped
in useAttachmentPreview() so each entry point owns its own modal
state without depending on a global Provider mount.

Modal sizing: max-w-6xl × min(90vh, 100vh - 2rem) — slightly larger
than create-issue's max-w-4xl since PDF / video need room, but capped
to viewport on small screens. Sub-renderers use h-full to follow the
fixed modal height instead of viewport-relative units.

Images are intentionally NOT touched — the existing ImageLightbox
(extensions/image-view.tsx) already handles them correctly. The new
modal would be churn without user-visible benefit.

Adds i18n keys under attachment.* (en + zh-Hans) and registers
Preview/Download/Upload in the conventions glossary so future
translations stay consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(desktop): enable Chromium PDF viewer for attachment preview

Adds webPreferences.plugins: true to the main BrowserWindow so the
bundled Chromium PDFium plugin activates inside iframes — required for
the attachment preview modal's PDF dispatch. Default is false in Electron;
without it <iframe src=*.pdf> renders blank.

Security trade-off, accepted intentionally and documented inline:
  1. This window already runs with webSecurity: false + sandbox: false,
     so plugins: true does NOT meaningfully widen the renderer's attack
     surface beyond what is already accepted.
  2. The only PDFs that reach an iframe here are signed CloudFront URLs
     we ourselves issued; user-supplied URLs are routed through
     setWindowOpenHandler → openExternalSafely and cannot land in this
     renderer.
  3. Chromium's PDFium plugin is itself sandboxed and only handles
     application/pdf — no Flash/Java/other historical plugin surfaces.

If we ever tighten webSecurity / sandbox, the follow-up is to host the
PDF viewer in a dedicated BrowserView with plugins scoped to that view,
keeping the main renderer plugin-free.

Old desktop builds ship without the preview modal, so the Eye button
never appears and PDF preview is gated by the same release — zero
regression risk for users on stale clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:24:15 +08:00

721 lines
29 KiB
TypeScript

"use client";
import { memo, useCallback, useRef, useState } from "react";
import { CheckCircle2, ChevronRight, Copy, Download, Eye, FileText, MoreHorizontal, Pencil, RotateCcw, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@multica/ui/components/ui/card";
import { Button } from "@multica/ui/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@multica/ui/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@multica/ui/components/ui/collapsible";
import { ActorAvatar } from "../../common/actor-avatar";
import { ReactionBar } from "@multica/ui/components/common/reaction-bar";
import { QuickEmojiPicker } from "@multica/ui/components/common/quick-emoji-picker";
import { cn } from "@multica/ui/lib/utils";
import { useActorName } from "@multica/core/workspace/hooks";
import { timeAgo } from "@multica/core/utils";
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, useDownloadAttachment, useAttachmentPreview, isPreviewable } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import { ReplyInput } from "./reply-input";
import type { TimelineEntry, Attachment } from "@multica/core/types";
import { useCommentCollapseStore, useCommentDraftStore } from "@multica/core/issues/stores";
import { useT } from "../../i18n";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface CommentCardProps {
issueId: string;
entry: TimelineEntry;
/**
* Flat list of every nested reply under this thread root, in render order.
* Computed once in `issue-detail.tsx`'s `timelineView` and stabilized so
* the array reference only changes when *this* thread's replies change —
* an unrelated thread receiving a new reply must NOT bust this card's
* memo. Passing the full Map here used to do exactly that.
*/
replies: TimelineEntry[];
currentUserId?: string;
/**
* True when the current user is a workspace owner/admin and can therefore
* moderate comments authored by anyone — restoring the admin override that
* the backend already grants at `comment.go:507-512`. Computed once in
* `issue-detail.tsx` and threaded down so neither this component nor
* `CommentRow` has to rerun the rule per row.
*/
canModerate?: boolean;
onReply: (parentId: string, content: string, attachmentIds?: string[]) => Promise<void>;
onEdit: (commentId: string, content: string) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
/** Toggle the resolved state on the thread root. Only invoked for root entries. */
onResolveToggle?: (commentId: string, resolved: boolean) => void;
/**
* When non-null, the thread root is currently rendered as a resolved-but-
* expanded card. Pass a "Collapse" affordance into the header so the user
* can fold the thread back to the bar; the parent owns the session state.
*/
onCollapseResolved?: () => void;
/** ID of the comment to highlight (flash animation). */
highlightedCommentId?: string | null;
}
// ---------------------------------------------------------------------------
// Shared delete confirmation dialog
// ---------------------------------------------------------------------------
function DeleteCommentDialog({
open,
onOpenChange,
onConfirm,
hasReplies,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
hasReplies?: boolean;
}) {
const { t } = useT("issues");
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t(($) => $.comment.delete_title)}</AlertDialogTitle>
<AlertDialogDescription>
{hasReplies
? t(($) => $.comment.delete_desc_with_replies)
: t(($) => $.comment.delete_desc)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t(($) => $.comment.cancel_action)}</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={onConfirm}>
{t(($) => $.comment.delete_action)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ---------------------------------------------------------------------------
// Standalone attachment list — renders attachments not already in the markdown
// ---------------------------------------------------------------------------
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
const { t } = useT("editor");
const download = useDownloadAttachment();
const preview = useAttachmentPreview();
if (!attachments?.length) return null;
// Skip attachments whose URL is already referenced in the markdown content,
// and duplicates of the same file (same name/type/size) that are referenced.
const standalone = content
? attachments.filter((a) => {
if (content.includes(a.url)) return false;
// Dedup: if another attachment with the same file identity is already
// inline in the content, this is a duplicate upload — skip it.
const hasSiblingInContent = attachments.some(
(other) =>
other.id !== a.id &&
other.filename === a.filename &&
other.content_type === a.content_type &&
other.size_bytes === a.size_bytes &&
content.includes(other.url),
);
if (hasSiblingInContent) return false;
return true;
})
: attachments;
if (!standalone.length) return null;
return (
<div className={cn("flex flex-col gap-1", className)}>
{standalone.map((a) => (
<div
key={a.id}
className="flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted"
>
<FileText className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{a.filename}</p>
</div>
{isPreviewable(a.content_type, a.filename) && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.preview)}
aria-label={t(($) => $.attachment.preview)}
onClick={() => preview.tryOpen(a)}
>
<Eye className="size-3.5" />
</button>
)}
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onClick={() => download(a.id)}
>
<Download className="size-3.5" />
</button>
</div>
))}
{preview.modal}
</div>
);
}
// ---------------------------------------------------------------------------
// Single comment row (used for both parent and replies within the same Card)
// ---------------------------------------------------------------------------
function CommentRow({
issueId,
entry,
currentUserId,
canModerate = false,
onEdit,
onDelete,
onToggleReaction,
}: {
issueId: string;
entry: TimelineEntry;
currentUserId?: string;
canModerate?: boolean;
onEdit: (commentId: string, content: string) => Promise<void>;
onDelete: (commentId: string) => void;
onToggleReaction: (commentId: string, emoji: string) => void;
}) {
const { t } = useT("issues");
const { getActorName } = useActorName();
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
const { uploadWithToast } = useFileUpload(api);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editEditorRef.current?.uploadFile(f)),
enabled: editing,
});
// Edit-mode draft: virtualization unmounts the card when it scrolls out
// of viewport, taking the in-progress edit with it. Persist via store
// so a scroll-away + scroll-back round-trip restores the user's edits.
// Key includes issueId so two issues with the same comment id (impossible
// but defensive) don't collide; cleared on cancel and on save.
const editDraftKey = `edit:${issueId}:${entry.id}` as const;
const getEditDraft = useCommentDraftStore.getState().getDraft;
const setEditDraft = useCommentDraftStore((s) => s.setDraft);
const clearEditDraft = useCommentDraftStore((s) => s.clearDraft);
// Read the snapshot once when the edit pass mounts; ContentEditor only
// honors `defaultValue` on mount, so a live store subscription here would
// cause an extra unmount/remount on every keystroke.
const editInitialValue = editing
? (getEditDraft(editDraftKey) ?? entry.content ?? "")
: (entry.content ?? "");
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
const canDeleteEntry = isOwn || canModerate;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
cancelledRef.current = false;
setEditing(true);
};
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
clearEditDraft(editDraftKey);
};
const saveEdit = async () => {
if (cancelledRef.current) return;
const trimmed = editEditorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
clearEditDraft(editDraftKey);
return;
}
try {
await onEdit(entry.id, trimmed);
setEditing(false);
clearEditDraft(editDraftKey);
} catch {
toast.error(t(($) => $.comment.update_failed));
}
};
const reactions = entry.reactions ?? [];
const contentText = entry.content ?? "";
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
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} enableHoverCard showStatusDot />
<span className="cursor-pointer 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 && (
<div className="ml-auto flex items-center gap-0.5">
<QuickEmojiPicker
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
align="end"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" className="text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
copyMarkdown(entry.content ?? "");
toast.success(t(($) => $.comment.copied_toast));
}}>
<Copy className="h-3.5 w-3.5" />
{t(($) => $.comment.copy_action)}
</DropdownMenuItem>
{(canEditEntry || canDeleteEntry) && (
<>
<DropdownMenuSeparator />
{canEditEntry && (
<DropdownMenuItem onClick={startEdit}>
<Pencil className="h-3.5 w-3.5" />
{t(($) => $.comment.edit_action)}
</DropdownMenuItem>
)}
{canEditEntry && canDeleteEntry && <DropdownMenuSeparator />}
{canDeleteEntry && (
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
{t(($) => $.comment.delete_action)}
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<DeleteCommentDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
onConfirm={() => onDelete(entry.id)}
/>
</div>
)}
</div>
{editing ? (
<div
{...dropZoneProps}
className="relative mt-1.5 pl-8"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="text-sm leading-relaxed">
<ContentEditor
ref={editEditorRef}
defaultValue={editInitialValue}
placeholder={t(($) => $.comment.edit_placeholder)}
onUpdate={(md) => {
if (md.trim().length > 0) setEditDraft(editDraftKey, md);
else clearEditDraft(editDraftKey);
}}
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
debounceMs={100}
currentIssueId={issueId}
attachments={entry.attachments}
/>
</div>
<div className="flex items-center justify-between mt-2">
<FileUploadButton
size="sm"
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
/>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
<Button size="sm" variant="outline" onClick={saveEdit}>{t(($) => $.comment.save_action)}</Button>
</div>
</div>
{isDragOver && <FileDropOverlay />}
</div>
) : (
<>
<div className="mt-1.5 pl-8 text-sm leading-relaxed text-foreground/85">
<ReadonlyContent content={entry.content ?? ""} attachments={entry.attachments} />
</div>
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-8" />
{!isTemp && (
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
getActorName={getActorName}
hideAddButton={!isLongContent}
className="mt-1.5 pl-8"
/>
)}
</>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// CommentCard — One Card per thread (parent + all replies flat inside)
// ---------------------------------------------------------------------------
function CommentCardImpl({
issueId,
entry,
replies,
currentUserId,
canModerate = false,
onReply,
onEdit,
onDelete,
onToggleReaction,
onResolveToggle,
onCollapseResolved,
highlightedCommentId,
}: CommentCardProps) {
const { t } = useT("issues");
const { getActorName } = useActorName();
const { uploadWithToast } = useFileUpload(api);
const isCollapsed = useCommentCollapseStore((s) => s.isCollapsed(issueId, entry.id));
const toggleCollapse = useCommentCollapseStore((s) => s.toggle);
const open = !isCollapsed;
const handleOpenChange = useCallback((_open: boolean) => toggleCollapse(issueId, entry.id), [toggleCollapse, issueId, entry.id]);
const [editing, setEditing] = useState(false);
const editEditorRef = useRef<ContentEditorRef>(null);
const cancelledRef = useRef(false);
const { isDragOver: parentDragOver, dropZoneProps: parentDropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editEditorRef.current?.uploadFile(f)),
enabled: editing,
});
// Edit-mode draft (root comment). Same rationale as CommentRow's draft.
const parentEditDraftKey = `edit:${issueId}:${entry.id}` as const;
const getParentEditDraft = useCommentDraftStore.getState().getDraft;
const setParentEditDraft = useCommentDraftStore((s) => s.setDraft);
const clearParentEditDraft = useCommentDraftStore((s) => s.clearDraft);
const parentEditInitialValue = editing
? (getParentEditDraft(parentEditDraftKey) ?? entry.content ?? "")
: (entry.content ?? "");
const isOwn = entry.actor_type === "member" && entry.actor_id === currentUserId;
// Author-only edit is the same as before; admins additionally get edit
// *and* delete on member-authored comments, plus delete on agent-authored
// ones. Edit on agent comments is intentionally never offered — agents
// own their own outputs.
const canEditEntry = isOwn || (canModerate && entry.actor_type === "member");
const canDeleteEntry = isOwn || canModerate;
const isTemp = entry.id.startsWith("temp-");
const [confirmDelete, setConfirmDelete] = useState(false);
const startEdit = () => {
cancelledRef.current = false;
setEditing(true);
};
const cancelEdit = () => {
cancelledRef.current = true;
setEditing(false);
clearParentEditDraft(parentEditDraftKey);
};
const saveEdit = async () => {
if (cancelledRef.current) return;
const trimmed = editEditorRef.current
?.getMarkdown()
?.replace(/(\n\s*)+$/, "")
.trim();
if (!trimmed || trimmed === (entry.content ?? "").trim()) {
setEditing(false);
clearParentEditDraft(parentEditDraftKey);
return;
}
try {
await onEdit(entry.id, trimmed);
setEditing(false);
clearParentEditDraft(parentEditDraftKey);
} catch {
toast.error(t(($) => $.comment.update_failed));
}
};
// The parent precomputes the flat thread (using collectThreadReplies),
// memoizes by thread, and stabilizes the array reference, so we render
// straight from `replies` instead of re-walking the graph on every render.
const allNestedReplies = replies;
const replyCount = allNestedReplies.length;
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
const reactions = entry.reactions ?? [];
const contentText = entry.content ?? "";
const isLongContent = contentText.length > 500 || contentText.split("\n").length > 8;
const isHighlighted = highlightedCommentId === entry.id;
return (
<Card className={cn("!py-0 !gap-0 overflow-hidden transition-colors duration-700", isTemp && "opacity-60", isHighlighted && "ring-2 ring-brand/50 bg-brand/5")}>
{onCollapseResolved && (
<button
type="button"
onClick={onCollapseResolved}
className="flex w-full items-center justify-between border-b border-border/50 px-4 py-2.5 text-left text-sm text-muted-foreground hover:bg-muted/50 transition-colors"
aria-label={t(($) => $.comment.resolve.collapse)}
>
<span className="flex items-center gap-2">
<CheckCircle2 className="h-3.5 w-3.5" />
{t(($) => $.comment.resolve.collapse)}
</span>
<ChevronRight className="h-3.5 w-3.5 -rotate-90" />
</button>
)}
<Collapsible open={open} onOpenChange={handleOpenChange}>
{/* Header — always visible, acts as toggle */}
<div className="px-4 py-3">
<div className="flex items-center gap-2.5">
<CollapsibleTrigger className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors">
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-90")} />
</CollapsibleTrigger>
<ActorAvatar actorType={entry.actor_type} actorId={entry.actor_id} size={24} enableHoverCard showStatusDot />
<span className="shrink-0 cursor-pointer text-sm font-medium">
{getActorName(entry.actor_type, entry.actor_id)}
</span>
<Tooltip>
<TooltipTrigger
render={
<span className="shrink-0 text-xs text-muted-foreground cursor-default">
{timeAgo(entry.created_at)}
</span>
}
/>
<TooltipContent side="top">
{new Date(entry.created_at).toLocaleString()}
</TooltipContent>
</Tooltip>
{!open && contentPreview && (
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{contentPreview}
</span>
)}
{!open && replyCount > 0 && (
<span className="shrink-0 text-xs text-muted-foreground">
{t(($) => $.comment.reply_count, { count: replyCount })}
</span>
)}
{open && !isTemp && (
<div className="ml-auto flex items-center gap-0.5">
<QuickEmojiPicker
onSelect={(emoji) => onToggleReaction(entry.id, emoji)}
align="end"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon-sm" className="text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
</Button>
}
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
copyMarkdown(entry.content ?? "");
toast.success(t(($) => $.comment.copied_toast));
}}>
<Copy className="h-3.5 w-3.5" />
{t(($) => $.comment.copy_action)}
</DropdownMenuItem>
{onResolveToggle && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onResolveToggle(entry.id, !entry.resolved_at)}>
{entry.resolved_at ? (
<>
<RotateCcw className="h-3.5 w-3.5" />
{t(($) => $.comment.resolve.unresolve_action)}
</>
) : (
<>
<CheckCircle2 className="h-3.5 w-3.5" />
{t(($) => $.comment.resolve.resolve_action)}
</>
)}
</DropdownMenuItem>
</>
)}
{(canEditEntry || canDeleteEntry) && (
<>
<DropdownMenuSeparator />
{canEditEntry && (
<DropdownMenuItem onClick={startEdit}>
<Pencil className="h-3.5 w-3.5" />
{t(($) => $.comment.edit_action)}
</DropdownMenuItem>
)}
{canEditEntry && canDeleteEntry && <DropdownMenuSeparator />}
{canDeleteEntry && (
<DropdownMenuItem onClick={() => setConfirmDelete(true)} variant="destructive">
<Trash2 className="h-3.5 w-3.5" />
{t(($) => $.comment.delete_action)}
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<DeleteCommentDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
onConfirm={() => onDelete(entry.id)}
hasReplies
/>
</div>
)}
</div>
</div>
{/* Collapsible body */}
<CollapsibleContent>
{/* Parent comment body */}
<div className="px-4 pb-3">
{editing ? (
<div
{...parentDropZoneProps}
className="relative pl-10"
onKeyDown={(e) => { if (e.key === "Escape") cancelEdit(); }}
>
<div className="text-sm leading-relaxed">
<ContentEditor
ref={editEditorRef}
defaultValue={parentEditInitialValue}
placeholder={t(($) => $.comment.edit_placeholder)}
onUpdate={(md) => {
if (md.trim().length > 0) setParentEditDraft(parentEditDraftKey, md);
else clearParentEditDraft(parentEditDraftKey);
}}
onSubmit={saveEdit}
onUploadFile={(file) => uploadWithToast(file, { issueId })}
debounceMs={100}
currentIssueId={issueId}
attachments={entry.attachments}
/>
</div>
<div className="flex items-center justify-between mt-2">
<FileUploadButton
size="sm"
onSelect={(file) => editEditorRef.current?.uploadFile(file)}
/>
<div className="flex items-center gap-2">
<Button size="sm" variant="ghost" onClick={cancelEdit}>{t(($) => $.comment.cancel_edit)}</Button>
<Button size="sm" variant="outline" onClick={saveEdit}>{t(($) => $.comment.save_action)}</Button>
</div>
</div>
{parentDragOver && <FileDropOverlay />}
</div>
) : (
<>
<div className="pl-10 text-sm leading-relaxed text-foreground/85">
<ReadonlyContent content={entry.content ?? ""} attachments={entry.attachments} />
</div>
<AttachmentList attachments={entry.attachments} content={entry.content} className="mt-1.5 pl-10" />
{!isTemp && (
<ReactionBar
reactions={reactions}
currentUserId={currentUserId}
onToggle={(emoji) => onToggleReaction(entry.id, emoji)}
getActorName={getActorName}
hideAddButton={!isLongContent}
className="mt-1.5 pl-10"
/>
)}
</>
)}
</div>
{/* Replies */}
{allNestedReplies.map((reply) => (
<div key={reply.id} id={`comment-${reply.id}`} className={cn("border-t border-border/50 px-4 transition-colors duration-700", highlightedCommentId === reply.id && "bg-brand/5")}>
<CommentRow
issueId={issueId}
entry={reply}
currentUserId={currentUserId}
canModerate={canModerate}
onEdit={onEdit}
onDelete={onDelete}
onToggleReaction={onToggleReaction}
/>
</div>
))}
{/* Reply input */}
<div className="border-t border-border/50 px-4 py-2.5">
<ReplyInput
issueId={issueId}
placeholder={t(($) => $.reply.placeholder)}
size="sm"
avatarType="member"
avatarId={currentUserId ?? ""}
draftKey={`reply:${issueId}:${entry.id}`}
onSubmit={(content, attachmentIds) => onReply(entry.id, content, attachmentIds)}
/>
</div>
</CollapsibleContent>
</Collapsible>
</Card>
);
}
// Memoized so a long timeline (e.g. Inbox-embedded IssueDetail with thousands
// of comments) does not re-render every card on each parent state update or
// WS-driven cache refresh. Default shallow comparison is sufficient: the
// timeline grouping is useMemo'd in issue-detail.tsx (stable Map ref), and
// every callback is stabilized via useCallback in use-issue-timeline.ts.
const CommentCard = memo(CommentCardImpl);
export { CommentCard, type CommentCardProps };