feat: message actions (#21)

<img width="962" height="780" alt="CleanShot 2026-02-22 at 13 55 42@2x"
src="https://github.com/user-attachments/assets/cf03df62-a80f-4f63-b8b7-2f6e67ed3d1f"
/>
<img width="488" height="420" alt="CleanShot 2026-02-22 at 13 56 42@2x"
src="https://github.com/user-attachments/assets/8deb8e08-3c18-45f4-a97c-8793c1e1a69f"
/>
This commit is contained in:
Maxwell
2026-02-22 17:27:51 -08:00
committed by GitHub
parent c6f2904469
commit 66badec869
8 changed files with 526 additions and 154 deletions
@@ -47,6 +47,30 @@ export async function addIssueComment(
}
}
export async function deleteIssueComment(
owner: string,
repo: string,
issueNumber: number,
commentId: number,
) {
const octokit = await getOctokit();
if (!octokit) return { error: "Not authenticated" };
try {
await octokit.issues.deleteComment({
owner,
repo,
comment_id: commentId,
});
await invalidateIssueCache(owner, repo, issueNumber);
invalidateRepoCache(owner, repo);
revalidatePath(`/repos/${owner}/${repo}/issues/${issueNumber}`);
return { success: true };
} catch (e: unknown) {
return { error: getErrorMessage(e) || "Failed to delete comment" };
}
}
export async function closeIssue(
owner: string,
repo: string,
@@ -21,6 +21,7 @@ const PR_ACTION_SCOPES: Record<string, PRMutationScope[]> = {
updateBase: ["detail", "list"],
review: ["detail"],
comment: ["detail"],
deleteComment: ["detail"],
reviewComment: ["detail"],
suggestion: ["detail"],
fileCommit: ["detail"],
@@ -214,6 +215,28 @@ export async function addPRComment(owner: string, repo: string, pullNumber: numb
}
}
export async function deletePRComment(
owner: string,
repo: string,
pullNumber: number,
commentId: number,
) {
const octokit = await getOctokit();
if (!octokit) return { error: "Not authenticated" };
try {
await octokit.issues.deleteComment({
owner,
repo,
comment_id: commentId,
});
await revalidateAfterPRMutation(owner, repo, pullNumber, "deleteComment");
return { success: true };
} catch (e: unknown) {
return { error: getErrorMessage(e) || "Failed to delete comment" };
}
}
export async function addPRReviewComment(
owner: string,
repo: string,
@@ -7,6 +7,7 @@ import { BotActivityGroup } from "@/components/pr/bot-activity-group";
import { OlderActivityGroup } from "@/components/issue/older-activity-group";
import { CollapsibleBody } from "@/components/issue/collapsible-body";
import { ReactionDisplay, type Reactions } from "@/components/shared/reaction-display";
import { ChatMessageWrapper } from "@/components/pr/chat-message-wrapper";
interface BaseUser {
login: string;
@@ -383,97 +384,162 @@ function ChatMessage({
);
}
return (
<div className="group">
<div
className={cn(
"border border-border/60 rounded-lg overflow-hidden",
isFirst && "border-border/80",
)}
>
// Description entries don't get the actions menu
if (entry.type === "description") {
return (
<div className="group">
<div
className={cn(
"flex items-center gap-2 px-3 py-1.5 border-b border-border/60",
isFirst ? "bg-card/80" : "bg-card/50",
"border border-border/60 rounded-lg overflow-hidden",
isFirst && "border-border/80",
)}
>
{entry.user ? (
<Link
href={`/users/${entry.user.login}`}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<Image
src={entry.user.avatar_url}
alt={entry.user.login}
width={16}
height={16}
className="rounded-full shrink-0"
/>
<span className="text-xs font-medium text-foreground/80">
{entry.user.login}
</span>
</Link>
) : (
<>
<div className="w-4 h-4 rounded-full bg-muted-foreground shrink-0" />
<span className="text-xs font-medium text-foreground/80">
ghost
</span>
</>
)}
{entry.type === "description" && (
<div
className={cn(
"flex items-center gap-2 px-3 py-1.5 border-b border-border/60",
isFirst ? "bg-card/80" : "bg-card/50",
)}
>
{entry.user ? (
<Link
href={`/users/${entry.user.login}`}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<Image
src={entry.user.avatar_url}
alt={entry.user.login}
width={16}
height={16}
className="rounded-full shrink-0"
/>
<span className="text-xs font-medium text-foreground/80">
{entry.user.login}
</span>
</Link>
) : (
<>
<div className="w-4 h-4 rounded-full bg-muted-foreground shrink-0" />
<span className="text-xs font-medium text-foreground/80">
ghost
</span>
</>
)}
<span className="text-[10px] text-muted-foreground/50">
opened
</span>
<span className="text-[10px] text-muted-foreground/40 ml-auto shrink-0">
<TimeAgo date={entry.created_at} />
</span>
</div>
{hasBody && renderedBody ? (
<div className="px-3 py-2.5">
{isLong ? (
<CollapsibleBody>
{renderedBody}
</CollapsibleBody>
) : (
renderedBody
)}
</div>
) : (
<div className="px-3 py-3">
<p className="text-xs text-muted-foreground/30 italic">
No description provided.
</p>
</div>
)}
{entry.type === "comment" &&
entry.author_association &&
entry.author_association !== "NONE" && (
<span className="text-[9px] px-1 py-px border border-border/60 text-muted-foreground/50 rounded">
{entry.author_association.toLowerCase()}
</span>
)}
<span className="text-[10px] text-muted-foreground/40 ml-auto shrink-0">
<TimeAgo date={entry.created_at} />
</span>
</div>
{hasBody && renderedBody ? (
<div className="px-3 py-2.5">
{isLong ? (
<CollapsibleBody>
{renderedBody}
</CollapsibleBody>
) : (
renderedBody
)}
<div className="px-3 pb-2">
<ReactionDisplay
reactions={entry.reactions ?? {}}
owner={owner}
repo={repo}
contentType="issue"
contentId={issueNumber}
/>
</div>
) : (
<div className="px-3 py-3">
<p className="text-xs text-muted-foreground/30 italic">
No description provided.
</p>
</div>
)}
<div className="px-3 pb-2">
<ReactionDisplay
reactions={entry.reactions ?? {}}
owner={owner}
repo={repo}
contentType={
entry.type === "description"
? "issue"
: "issueComment"
}
contentId={
entry.type === "description"
? issueNumber
: (entry.id as number)
}
/>
</div>
</div>
</div>
);
}
// Comment entries use the wrapper with actions menu
const headerContent = (
<>
{entry.user ? (
<Link
href={`/users/${entry.user.login}`}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<Image
src={entry.user.avatar_url}
alt={entry.user.login}
width={16}
height={16}
className="rounded-full shrink-0"
/>
<span className="text-xs font-medium text-foreground/80">
{entry.user.login}
</span>
</Link>
) : (
<>
<div className="w-4 h-4 rounded-full bg-muted-foreground shrink-0" />
<span className="text-xs font-medium text-foreground/80">
ghost
</span>
</>
)}
{entry.author_association && entry.author_association !== "NONE" && (
<span className="text-[9px] px-1 py-px border border-border/60 text-muted-foreground/50 rounded">
{entry.author_association.toLowerCase()}
</span>
)}
<span className="text-[10px] text-muted-foreground/40 ml-auto shrink-0">
<TimeAgo date={entry.created_at} />
</span>
</>
);
const bodyContent =
hasBody && renderedBody ? (
<div className="px-3 py-2.5">
{isLong ? (
<CollapsibleBody>{renderedBody}</CollapsibleBody>
) : (
renderedBody
)}
</div>
) : (
<div className="px-3 py-3">
<p className="text-xs text-muted-foreground/30 italic">
No description provided.
</p>
</div>
);
const reactionsContent = (
<ReactionDisplay
reactions={entry.reactions ?? {}}
owner={owner}
repo={repo}
contentType="issueComment"
contentId={entry.id as number}
/>
);
return (
<ChatMessageWrapper
headerContent={headerContent}
bodyContent={bodyContent}
reactionsContent={reactionsContent}
owner={owner}
repo={repo}
contentType="issue"
issueNumber={issueNumber}
commentId={entry.id as number}
body={entry.body}
/>
);
}
@@ -4,6 +4,7 @@ import { useState } from "react";
import Image from "next/image";
import { ChevronRight, Bot } from "lucide-react";
import { cn } from "@/lib/utils";
import { DeletedCommentsProvider, useDeletedComments } from "./deleted-comments-context";
interface BotActivityGroupProps {
count: number;
@@ -13,7 +14,23 @@ interface BotActivityGroupProps {
}
export function BotActivityGroup({ count, botNames, avatars, children }: BotActivityGroupProps) {
return (
<DeletedCommentsProvider>
<BotActivityGroupInner count={count} botNames={botNames} avatars={avatars}>
{children}
</BotActivityGroupInner>
</DeletedCommentsProvider>
);
}
function BotActivityGroupInner({ count, botNames, avatars, children }: BotActivityGroupProps) {
const [expanded, setExpanded] = useState(false);
const deletedContext = useDeletedComments();
const displayCount = count - (deletedContext?.deletedCount ?? 0);
if (displayCount <= 0) {
return null;
}
const label =
botNames.length === 1
@@ -53,7 +70,7 @@ export function BotActivityGroup({ count, botNames, avatars, children }: BotActi
{label}
</span>
<span className="text-[10px] text-muted-foreground/30">
{count} {count === 1 ? "comment" : "comments"}
{displayCount} {displayCount === 1 ? "comment" : "comments"}
</span>
</button>
@@ -0,0 +1,76 @@
"use client";
import { useState, type ReactNode } from "react";
import { MessageActionsMenu } from "./message-actions-menu";
import { useDeletedComments } from "./deleted-comments-context";
type ChatMessageWrapperProps = {
headerContent: ReactNode;
bodyContent: ReactNode;
reactionsContent: ReactNode;
owner: string;
repo: string;
commentId: number;
body: string;
} & (
| { contentType: "pr"; pullNumber: number; issueNumber?: never }
| { contentType: "issue"; issueNumber: number; pullNumber?: never }
);
export function ChatMessageWrapper({
headerContent,
bodyContent,
reactionsContent,
owner,
repo,
contentType,
pullNumber,
issueNumber,
commentId,
body,
}: ChatMessageWrapperProps) {
const [deleted, setDeleted] = useState(false);
const deletedContext = useDeletedComments();
if (deleted) {
return null;
}
const handleDelete = () => {
setDeleted(true);
deletedContext?.markDeleted();
};
return (
<div className="group">
<div className="border border-border/60 rounded-lg overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border/60 bg-card/50">
{headerContent}
{contentType === "pr" ? (
<MessageActionsMenu
owner={owner}
repo={repo}
contentType="pr"
pullNumber={pullNumber}
commentId={commentId}
body={body}
onDelete={handleDelete}
/>
) : (
<MessageActionsMenu
owner={owner}
repo={repo}
contentType="issue"
issueNumber={issueNumber}
commentId={commentId}
body={body}
onDelete={handleDelete}
/>
)}
</div>
{bodyContent}
<div className="px-3 pb-2">{reactionsContent}</div>
</div>
</div>
);
}
@@ -0,0 +1,28 @@
"use client";
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
interface DeletedCommentsContextValue {
deletedCount: number;
markDeleted: () => void;
}
const DeletedCommentsContext = createContext<DeletedCommentsContextValue | null>(null);
export function DeletedCommentsProvider({ children }: { children: ReactNode }) {
const [deletedCount, setDeletedCount] = useState(0);
const markDeleted = useCallback(() => {
setDeletedCount((c) => c + 1);
}, []);
return (
<DeletedCommentsContext.Provider value={{ deletedCount, markDeleted }}>
{children}
</DeletedCommentsContext.Provider>
);
}
export function useDeletedComments() {
return useContext(DeletedCommentsContext);
}
@@ -0,0 +1,133 @@
"use client";
import { useState, useEffect } from "react";
import { MoreHorizontal, Link, Copy, Quote, Check, Trash2, Loader2 } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { deletePRComment } from "@/app/(app)/repos/[owner]/[repo]/pulls/pr-actions";
import { deleteIssueComment } from "@/app/(app)/repos/[owner]/[repo]/issues/issue-actions";
type MessageActionsMenuProps = {
owner: string;
repo: string;
commentId: number;
body: string;
onDelete?: () => void;
} & (
| { contentType: "pr"; pullNumber: number; issueNumber?: never }
| { contentType: "issue"; issueNumber: number; pullNumber?: never }
);
export function MessageActionsMenu({
owner,
repo,
contentType,
pullNumber,
issueNumber,
commentId,
body,
onDelete,
}: MessageActionsMenuProps) {
const [copied, setCopied] = useState(false);
const [open, setOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const number = contentType === "pr" ? pullNumber : issueNumber;
const urlType = contentType === "pr" ? "pull" : "issues";
const commentUrl = `https://github.com/${owner}/${repo}/${urlType}/${number}#issuecomment-${commentId}`;
useEffect(() => {
if (copied) {
const timer = setTimeout(() => setCopied(false), 1500);
return () => clearTimeout(timer);
}
}, [copied]);
const handleCopyLink = async (e: Event) => {
e.preventDefault();
await navigator.clipboard.writeText(commentUrl);
setCopied(true);
setOpen(false);
};
const handleCopyText = async (e: Event) => {
e.preventDefault();
await navigator.clipboard.writeText(body);
setCopied(true);
setOpen(false);
};
const handleQuoteReply = async (e: Event) => {
e.preventDefault();
const quoted = body
.split("\n")
.map((line) => `> ${line}`)
.join("\n");
await navigator.clipboard.writeText(quoted + "\n\n");
setCopied(true);
setOpen(false);
};
const handleDelete = async (e: Event) => {
e.preventDefault();
setDeleting(true);
setOpen(false);
const result =
contentType === "pr"
? await deletePRComment(owner, repo, pullNumber!, commentId)
: await deleteIssueComment(owner, repo, issueNumber!, commentId);
if (result.error) {
alert(result.error);
} else {
onDelete?.();
}
setDeleting(false);
};
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
className="p-0.5 rounded hover:bg-accent text-muted-foreground/40 hover:text-muted-foreground transition-colors"
aria-label="Message actions"
disabled={deleting}
>
{deleting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : copied ? (
<Check className="w-3.5 h-3.5 text-green-500" />
) : (
<MoreHorizontal className="w-3.5 h-3.5" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onSelect={handleCopyLink}>
<Link className="w-3.5 h-3.5" />
<span>Copy link</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleCopyText}>
<Copy className="w-3.5 h-3.5" />
<span>Copy text</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleQuoteReply}>
<Quote className="w-3.5 h-3.5" />
<span>Quote reply</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={handleDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+78 -73
View File
@@ -9,6 +9,7 @@ import { BotActivityGroup } from "./bot-activity-group";
import { CommitActivityGroup } from "./commit-activity-group";
import { ReactionDisplay, type Reactions } from "@/components/shared/reaction-display";
import { CollapsibleDescription } from "./collapsible-description";
import { ChatMessageWrapper } from "./chat-message-wrapper";
interface BaseUser {
login: string;
@@ -200,9 +201,6 @@ export async function PRConversation({
entry={
entry
}
isFirst={
false
}
owner={
owner
}
@@ -227,7 +225,7 @@ export async function PRConversation({
);
}
const { entry, index } = item;
const { entry } = item;
if (entry.type === "review") {
return (
<ReviewCardWrapper
@@ -254,7 +252,6 @@ export async function PRConversation({
: `comment-${entry.id}`
}
entry={entry}
isFirst={index === 0}
owner={owner}
repo={repo}
pullNumber={pullNumber}
@@ -275,13 +272,11 @@ export async function PRConversation({
async function ChatMessage({
entry,
isFirst,
owner,
repo,
pullNumber,
}: {
entry: DescriptionEntry | CommentEntry;
isFirst: boolean;
owner: string;
repo: string;
pullNumber: number;
@@ -323,77 +318,87 @@ async function ChatMessage({
);
}
return (
<div className="group">
<div className="border border-border/60 rounded-lg overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border/60 bg-card/50">
{entry.user ? (
<Link
href={`/users/${entry.user.login}`}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<Image
src={entry.user.avatar_url}
alt={entry.user.login}
width={16}
height={16}
className="rounded-full shrink-0"
/>
<span className="text-xs font-medium text-foreground/80">
{entry.user.login}
</span>
</Link>
) : (
<>
<div className="w-4 h-4 rounded-full bg-muted-foreground shrink-0" />
<span className="text-xs font-medium text-foreground/80">
ghost
</span>
</>
)}
{entry.type === "comment" &&
entry.author_association &&
entry.author_association !== "NONE" && (
<span className="text-[9px] px-1 py-px border border-border text-muted-foreground/50 rounded">
{entry.author_association.toLowerCase()}
</span>
)}
<span className="text-[10px] text-muted-foreground/40 ml-auto shrink-0">
<TimeAgo date={entry.created_at} />
</span>
</div>
{hasBody ? (
<div className="px-3 py-2.5">
<MarkdownRenderer
content={entry.body}
className="ghmd-sm"
issueRefContext={{ owner, repo }}
/>
</div>
) : (
<div className="px-3 py-3">
<p className="text-xs text-muted-foreground/30 italic">
No description provided.
</p>
</div>
)}
<div className="px-3 pb-2">
<ReactionDisplay
reactions={entry.reactions ?? {}}
owner={owner}
repo={repo}
contentType="issueComment"
contentId={entry.id as number}
const headerContent = (
<>
{entry.user ? (
<Link
href={`/users/${entry.user.login}`}
className="flex items-center gap-2 hover:text-foreground transition-colors"
>
<Image
src={entry.user.avatar_url}
alt={entry.user.login}
width={16}
height={16}
className="rounded-full shrink-0"
/>
</div>
</div>
<span className="text-xs font-medium text-foreground/80">
{entry.user.login}
</span>
</Link>
) : (
<>
<div className="w-4 h-4 rounded-full bg-muted-foreground shrink-0" />
<span className="text-xs font-medium text-foreground/80">
ghost
</span>
</>
)}
{entry.type === "comment" &&
entry.author_association &&
entry.author_association !== "NONE" && (
<span className="text-[9px] px-1 py-px border border-border text-muted-foreground/50 rounded">
{entry.author_association.toLowerCase()}
</span>
)}
<span className="text-[10px] text-muted-foreground/40 ml-auto shrink-0">
<TimeAgo date={entry.created_at} />
</span>
</>
);
const bodyContent = hasBody ? (
<div className="px-3 py-2.5">
<MarkdownRenderer
content={entry.body}
className="ghmd-sm"
issueRefContext={{ owner, repo }}
/>
</div>
) : (
<div className="px-3 py-3">
<p className="text-xs text-muted-foreground/30 italic">
No description provided.
</p>
</div>
);
const reactionsContent = (
<ReactionDisplay
reactions={entry.reactions ?? {}}
owner={owner}
repo={repo}
contentType="issueComment"
contentId={entry.id as number}
/>
);
return (
<ChatMessageWrapper
headerContent={headerContent}
bodyContent={bodyContent}
reactionsContent={reactionsContent}
owner={owner}
repo={repo}
contentType="pr"
pullNumber={pullNumber}
commentId={entry.id as number}
body={entry.body}
/>
);
}
async function ReviewCardWrapper({
function ReviewCardWrapper({
entry,
owner,
repo,