328 lines
9.3 KiB
TypeScript
328 lines
9.3 KiB
TypeScript
'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>
|
||
);
|
||
}
|