- API: issue label bulk add, search messages, room WS push, openapi - Frontend: notify page, issue detail AI triage banner, search page, repository settings, preferences, PR components, file browser - Room: DiscordChannelSidebar, RoomPinPanel, RoomMessageActions, RoomThreadPanel, MessageContent, repository-context - Frontend SDK regenerated from openapi.json
169 lines
6.1 KiB
TypeScript
169 lines
6.1 KiB
TypeScript
import type { MessageWithMeta } from '@/contexts';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { useUser } from '@/contexts';
|
|
import { cn } from '@/lib/utils';
|
|
import { AlertCircle, Copy, Edit, GitPullRequest, LayoutDashboard, MoreHorizontal, Reply, Trash2 } from 'lucide-react';
|
|
import { useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { getSenderUserUid } from './sender';
|
|
|
|
interface RoomMessageActionsProps {
|
|
message: MessageWithMeta;
|
|
onEdit?: () => void;
|
|
onRevoke?: () => void;
|
|
onReply?: () => void;
|
|
}
|
|
|
|
export function RoomMessageActions({ message, onEdit, onRevoke, onReply }: RoomMessageActionsProps) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const { user } = useUser();
|
|
const isOwner = user?.uid === getSenderUserUid(message);
|
|
|
|
const handleCopy = async () => {
|
|
if (message.content_type !== 'text') return;
|
|
try {
|
|
await navigator.clipboard.writeText(message.content);
|
|
toast.success('Message copied');
|
|
} catch {
|
|
toast.error('Failed to copy message');
|
|
} finally {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'absolute -top-3 right-4 z-20 items-center gap-1 rounded-md border border-border/70 bg-popover/95 p-1 shadow-sm',
|
|
isOpen ? 'flex' : 'hidden group-hover:flex',
|
|
)}
|
|
>
|
|
{onReply && (
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onReply} title="Reply">
|
|
<Reply className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
|
|
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
|
<DropdownMenuTrigger className="inline-flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground">
|
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" side="bottom" sideOffset={6}>
|
|
{message.content_type === 'text' && (
|
|
<DropdownMenuItem onClick={handleCopy}>
|
|
<Copy className="mr-2 h-4 w-4" />
|
|
Copy
|
|
</DropdownMenuItem>
|
|
)}
|
|
{message.content_type === 'text' && (
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
toast.info('Creating issue from message…', {
|
|
description: 'This will open the issue creation form with the message content pre-filled.',
|
|
action: {
|
|
label: 'Create',
|
|
onClick: () => {
|
|
// TODO: wire to POST /api/issue/{project}/issues/from-message
|
|
const title = message.content.split('\n')[0].slice(0, 80);
|
|
const body = `Converted from room message (${message.id})\n\n${message.content}`;
|
|
const params = new URLSearchParams({ title, body });
|
|
window.open(`/project/-/issues/new?${params}`, '_blank');
|
|
},
|
|
},
|
|
});
|
|
setIsOpen(false);
|
|
}}
|
|
>
|
|
<AlertCircle className="mr-2 h-4 w-4" />
|
|
Create Issue
|
|
</DropdownMenuItem>
|
|
)}
|
|
{message.content_type === 'text' && /\n```[\s\S]*?\n```/.test(message.content) && (
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
setIsOpen(false);
|
|
toast.info('Creating PR from message…', {
|
|
description: 'Open the PR creation form with the code snippet pre-filled.',
|
|
action: {
|
|
label: 'Open',
|
|
onClick: () => {
|
|
// TODO: wire to POST /api/repo_pr/{ns}/{repo}/pulls/from-message
|
|
const params = new URLSearchParams({
|
|
body: `Converted from room message (${message.id})\n\n${message.content}`,
|
|
});
|
|
window.open(`/pulls/new?${params}`, '_blank');
|
|
},
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<GitPullRequest className="mr-2 h-4 w-4" />
|
|
Create PR
|
|
</DropdownMenuItem>
|
|
)}
|
|
{message.content_type === 'text' && (
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
setIsOpen(false);
|
|
toast.info('Adding to board…', {
|
|
description: 'Open the kanban board with this message as the card description.',
|
|
action: {
|
|
label: 'Open Board',
|
|
onClick: () => {
|
|
// TODO: wire to POST /api/board/cards
|
|
const params = new URLSearchParams({
|
|
title: message.content.split('\n')[0].slice(0, 80),
|
|
description: message.content,
|
|
});
|
|
window.open(`/boards/new?${params}`, '_blank');
|
|
},
|
|
},
|
|
});
|
|
}}
|
|
>
|
|
<LayoutDashboard className="mr-2 h-4 w-4" />
|
|
Add to Board
|
|
</DropdownMenuItem>
|
|
)}
|
|
{isOwner && onEdit && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
onEdit();
|
|
setIsOpen(false);
|
|
}}
|
|
>
|
|
<Edit className="mr-2 h-4 w-4" />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
{isOwner && onRevoke && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="text-destructive focus:text-destructive"
|
|
onClick={() => {
|
|
onRevoke();
|
|
setIsOpen(false);
|
|
}}
|
|
>
|
|
<Trash2 className="mr-2 h-4 w-4" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
}
|