Files
IdP/apps/frontend/components/admin/admin-inspector-message-body.tsx
2026-07-01 19:19:27 +03:00

274 lines
8.6 KiB
TypeScript
Raw 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 type { ReactNode } from 'react';
import { Loader2, Trash2 } from 'lucide-react';
import { ChatMediaAlbumGrid } from '@/components/chat/chat-media-album-grid';
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
import { AdminChatMessage, ChatMessage } from '@/lib/api';
import { groupChatMessagesForDisplay, parseChatMediaMetadata } from '@/lib/chat-media-album';
import { cn } from '@/lib/utils';
import { AdminChatMediaEntry, adminMessageToChatMessage } from '@/components/admin/use-admin-chat-media';
function AdminImageBubble({
message,
media,
onOpen
}: {
message: ChatMessage;
media?: AdminChatMediaEntry;
onOpen: () => void;
}) {
const meta = parseChatMediaMetadata(message.metadataJson);
const fileName = meta.fileName || message.content || 'Фото';
if (media?.status === 'error') {
return <p className="text-sm text-[#667085]">{media.errorMessage ?? 'Фото недоступно'}</p>;
}
if (!media?.blobUrl || media.status === 'loading') {
return <MediaImageSkeleton className="max-h-56 w-full max-w-[240px]" rounded="xl" />;
}
return (
<button type="button" className="block overflow-hidden rounded-xl" onClick={onOpen}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={media.blobUrl} alt={fileName} className="max-h-56 max-w-full rounded-xl object-cover" />
</button>
);
}
export function AdminInspectorMessageBody({
messages,
roomId,
mediaUrls,
onOpenImage
}: {
messages: AdminChatMessage[];
roomId: string;
mediaUrls: Record<string, AdminChatMediaEntry>;
onOpenImage: (message: ChatMessage) => void;
}) {
const chatMessages = messages.map((message) => adminMessageToChatMessage(message, roomId));
const displayItems = groupChatMessagesForDisplay(chatMessages);
return (
<div className="space-y-2">
{displayItems.map((item) => {
if (item.kind === 'album') {
const captionMessage = item.messages.find((message) => message.content?.trim());
return (
<div key={item.albumId} className="space-y-2">
<ChatMediaAlbumGrid
messages={item.messages}
mediaUrls={mediaUrls}
onImageClick={(message) => onOpenImage(message)}
/>
{captionMessage?.content ? (
<p className="whitespace-pre-wrap break-words text-sm text-[#344054]">{captionMessage.content}</p>
) : null}
</div>
);
}
const message = item.message;
const adminMessage = messages.find((entry) => entry.id === message.id);
if (!adminMessage) return null;
if (message.isEncrypted) {
return (
<p key={message.id} className="text-sm text-[#667085]">
[E2E недоступно для модерации]
</p>
);
}
if (message.storageKey && message.type === 'IMAGE') {
return (
<AdminImageBubble
key={message.id}
message={message}
media={message.storageKey ? mediaUrls[message.storageKey] : undefined}
onOpen={() => onOpenImage(message)}
/>
);
}
if (message.type === 'FILE' && message.storageKey) {
const meta = parseChatMediaMetadata(message.metadataJson);
return (
<p key={message.id} className="text-sm text-[#667085]">
📎 {meta.fileName || message.content || 'Файл'}
</p>
);
}
if (message.type === 'VOICE' || message.type === 'AUDIO') {
return (
<p key={message.id} className="text-sm text-[#667085]">
🎤 Голосовое сообщение
</p>
);
}
if (message.type === 'POLL') {
return (
<p key={message.id} className="text-sm text-[#667085]">
📊 Опрос
</p>
);
}
const text = adminMessage.content?.trim() || adminMessage.preview;
if (!text || text === '[Пустое сообщение]') {
return null;
}
return (
<p
key={message.id}
className={cn(
'whitespace-pre-wrap break-words text-sm text-[#344054]',
message.type === 'SYSTEM' && 'text-[#667085] italic'
)}
>
{text}
</p>
);
})}
</div>
);
}
export function AdminInspectorMessageFeed({
messages,
roomId,
mediaUrls,
deletingMessageId,
onDelete,
onOpenImage
}: {
messages: AdminChatMessage[];
roomId: string;
mediaUrls: Record<string, AdminChatMediaEntry>;
deletingMessageId: string | null;
onDelete: (messageId: string) => void;
onOpenImage: (message: ChatMessage) => void;
}) {
const chatMessages = messages.map((message) => adminMessageToChatMessage(message, roomId));
const displayItems = groupChatMessagesForDisplay(chatMessages);
const adminById = new Map(messages.map((message) => [message.id, message]));
return (
<div className="space-y-2">
{displayItems.map((item) => {
if (item.kind === 'album') {
const anchor = adminById.get(item.messages[0]!.id);
if (!anchor) return null;
return (
<AdminInspectorMessageRow
key={item.albumId}
message={anchor}
roomId={roomId}
mediaUrls={mediaUrls}
deleting={item.messages.some((message) => deletingMessageId === message.id)}
onDelete={() => onDelete(anchor.id)}
onOpenImage={onOpenImage}
bodyOverride={
<AdminInspectorMessageBody
messages={item.messages
.map((message) => adminById.get(message.id))
.filter((entry): entry is AdminChatMessage => Boolean(entry))}
roomId={roomId}
mediaUrls={mediaUrls}
onOpenImage={onOpenImage}
/>
}
/>
);
}
const adminMessage = adminById.get(item.message.id);
if (!adminMessage) return null;
return (
<AdminInspectorMessageRow
key={item.message.id}
message={adminMessage}
roomId={roomId}
mediaUrls={mediaUrls}
deleting={deletingMessageId === adminMessage.id}
onDelete={() => onDelete(adminMessage.id)}
onOpenImage={onOpenImage}
/>
);
})}
</div>
);
}
export function AdminInspectorMessageRow({
message,
roomId,
mediaUrls,
deleting,
onDelete,
onOpenImage,
bodyOverride
}: {
message: AdminChatMessage;
roomId: string;
mediaUrls: Record<string, AdminChatMediaEntry>;
deleting: boolean;
onDelete: () => void;
onOpenImage: (message: ChatMessage) => void;
bodyOverride?: ReactNode;
}) {
return (
<div
className={cn(
'rounded-2xl border px-3 py-2',
message.isDeleted ? 'border-red-100 bg-red-50' : 'border-[#eceef4] bg-[#fafbff]'
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{message.senderName}</span>
<span className="text-xs text-[#667085]">
{new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(message.createdAt))}
</span>
{message.isDeleted ? <span className="text-xs font-medium text-red-600">Удалено</span> : null}
</div>
<div className="mt-1">
{bodyOverride ?? (
<AdminInspectorMessageBody
messages={[message]}
roomId={roomId}
mediaUrls={mediaUrls}
onOpenImage={onOpenImage}
/>
)}
</div>
</div>
{!message.isDeleted && !message.isEncrypted ? (
<button
type="button"
className="shrink-0 rounded-lg p-2 text-red-600 transition hover:bg-red-50 disabled:opacity-60"
disabled={deleting}
aria-label="Удалить сообщение"
onClick={onDelete}
>
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</button>
) : null}
</div>
</div>
);
}