mirror of
https://github.com/better-auth/better-hub
synced 2026-08-09 12:26:04 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user