Files
IdP/apps/frontend/components/chat/chat-message-context-menu.tsx
2026-06-26 13:01:52 +03:00

328 lines
9.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { CheckCircle2, Copy, Forward, Pencil, Pin, PinOff, Reply, Trash2, XCircle } from 'lucide-react';
import { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger
} from '@/components/ui/context-menu';
import { suppressChatDragSelection } from '@/hooks/use-chat-drag-selection';
import type { ChatMessage } from '@/lib/api';
import { QUICK_REACTIONS, canDeleteMessage, canEditMessage, getMessageCopyText } from '@/lib/chat-message-utils';
import { cn } from '@/lib/utils';
interface ChatMessageContextMenuProps {
message: ChatMessage;
userId?: string;
isE2E?: boolean;
visibleText?: string;
children: React.ReactNode;
onEdit?: () => void;
onDelete?: () => void;
onReply?: () => void;
onForward?: () => void;
onTogglePin?: () => void;
onSelect?: () => void;
onToggleReaction?: (emoji: string) => void;
onCopy?: () => void;
}
export function ChatMessageContextMenu({
message,
userId,
isE2E,
visibleText,
children,
onEdit,
onDelete,
onReply,
onForward,
onTogglePin,
onSelect,
onToggleReaction,
onCopy
}: ChatMessageContextMenuProps) {
const editable = canEditMessage(message, userId, isE2E);
const deletable = canDeleteMessage(message, userId);
const copyText = getMessageCopyText(message, visibleText);
const disabled = message.isDeleted;
function runMenuAction(action?: () => void) {
suppressChatDragSelection();
action?.();
}
return (
<ContextMenu
onOpenChange={(open) => {
if (open) suppressChatDragSelection(800);
}}
>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="min-w-[240px]">
{!disabled ? (
<>
<div className="mb-1 flex items-center gap-1 overflow-x-auto px-1 py-1">
{QUICK_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-lg transition hover:bg-[#eef1f6]"
onClick={() => runMenuAction(() => onToggleReaction?.(emoji))}
>
{emoji}
</button>
))}
</div>
<ContextMenuSeparator />
</>
) : null}
{onReply && !disabled ? (
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onReply);
}}
>
<Reply className="h-4 w-4" />
Ответить
</ContextMenuItem>
) : null}
{editable ? (
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onEdit);
}}
>
<Pencil className="h-4 w-4" />
Изменить
</ContextMenuItem>
) : null}
{copyText ? (
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(() => {
void navigator.clipboard.writeText(copyText);
onCopy?.();
});
}}
>
<Copy className="h-4 w-4" />
Копировать текст
</ContextMenuItem>
) : null}
{onForward && !disabled && !message.isEncrypted ? (
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onForward);
}}
>
<Forward className="h-4 w-4" />
Переслать
</ContextMenuItem>
) : null}
{onTogglePin && !disabled ? (
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onTogglePin);
}}
>
{message.isPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
{message.isPinned ? 'Открепить' : 'Закрепить'}
</ContextMenuItem>
) : null}
{deletable ? (
<ContextMenuItem
className="text-red-600 data-[highlighted]:bg-red-50 data-[highlighted]:text-red-700"
onSelect={(event) => {
event.preventDefault();
runMenuAction(onDelete);
}}
>
<Trash2 className="h-4 w-4" />
Удалить
</ContextMenuItem>
) : null}
{onSelect && !disabled ? (
<>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onSelect);
}}
>
<CheckCircle2 className="h-4 w-4" />
Выделить
</ContextMenuItem>
</>
) : null}
{!disabled && message.reactions?.length ? (
<div className="px-2 pb-1 pt-2">
<ChatMessageReactions reactions={message.reactions} onToggle={onToggleReaction} compact />
</div>
) : null}
</ContextMenuContent>
</ContextMenu>
);
}
interface ChatSelectionContextMenuProps {
count: number;
canPin?: boolean;
pinned?: boolean;
children: React.ReactNode;
onReply?: () => void;
onCopy?: () => void;
onForward?: () => void;
onDelete?: () => void;
onTogglePin?: () => void;
onCancel?: () => void;
}
export function ChatSelectionContextMenu({
count,
canPin,
pinned,
children,
onReply,
onCopy,
onForward,
onDelete,
onTogglePin,
onCancel
}: ChatSelectionContextMenuProps) {
function runMenuAction(action?: () => void) {
suppressChatDragSelection();
action?.();
}
return (
<ContextMenu
onOpenChange={(open) => {
if (open) suppressChatDragSelection(800);
}}
>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="min-w-[260px]">
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onReply);
}}
>
<Reply className="h-4 w-4" />
Ответить
</ContextMenuItem>
{canPin && onTogglePin ? (
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onTogglePin);
}}
>
{pinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
{pinned ? 'Открепить' : 'Закрепить'}
</ContextMenuItem>
) : null}
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onCopy);
}}
>
<Copy className="h-4 w-4" />
Копировать выбранное как текст
</ContextMenuItem>
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onForward);
}}
>
<Forward className="h-4 w-4" />
Переслать выбранное
</ContextMenuItem>
<ContextMenuItem
className="text-red-600 data-[highlighted]:bg-red-50 data-[highlighted]:text-red-700"
onSelect={(event) => {
event.preventDefault();
runMenuAction(onDelete);
}}
>
<Trash2 className="h-4 w-4" />
Удалить выбранное
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={(event) => {
event.preventDefault();
runMenuAction(onCancel);
}}
>
<XCircle className="h-4 w-4" />
Отменить выбор
</ContextMenuItem>
<span className="sr-only">Выбрано сообщений: {count}</span>
</ContextMenuContent>
</ContextMenu>
);
}
interface SelectableMessageWrapperProps {
selected?: boolean;
selectionMode?: boolean;
align?: 'left' | 'right';
onClick?: () => void;
className?: string;
children: React.ReactNode;
}
export function SelectableMessageWrapper({
selected,
selectionMode,
align = 'left',
onClick,
className,
children
}: SelectableMessageWrapperProps) {
return (
<div
className={cn(
'relative w-fit max-w-[78%] shrink-0',
selectionMode && 'cursor-pointer',
selectionMode && (align === 'right' ? 'mr-7' : 'ml-7'),
className
)}
onClick={selectionMode ? onClick : undefined}
>
{selectionMode ? (
<span
className={cn(
'absolute top-1/2 z-10 flex h-5 w-5 -translate-y-1/2 items-center justify-center rounded-full border text-[10px] shadow-sm transition',
align === 'right' ? 'right-0 translate-x-[calc(100%+8px)]' : 'left-0 -translate-x-[calc(100%+8px)]',
selected ? 'border-[#3390ec] bg-[#3390ec] text-white' : 'border-[#dce3ec] bg-white text-transparent'
)}
>
</span>
) : null}
<div
className={cn(
'rounded-[18px] transition',
selected && 'ring-2 ring-[#3390ec] ring-offset-2 ring-offset-[#eef1f6]'
)}
>
{children}
</div>
</div>
);
}