307 lines
9.6 KiB
TypeScript
307 lines
9.6 KiB
TypeScript
'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 === 'VIDEO' && message.storageKey) {
|
||
const media = mediaUrls[message.storageKey];
|
||
if (media?.blobUrl) {
|
||
return (
|
||
<video key={message.id} src={media.blobUrl} controls className="max-h-56 max-w-full rounded-xl" />
|
||
);
|
||
}
|
||
return (
|
||
<p key={message.id} className="text-sm text-[#667085]">
|
||
🎬 Видео
|
||
</p>
|
||
);
|
||
}
|
||
|
||
if (message.type === 'VIDEO_NOTE' && message.storageKey) {
|
||
const media = mediaUrls[message.storageKey];
|
||
if (media?.blobUrl) {
|
||
return (
|
||
<video
|
||
key={message.id}
|
||
src={media.blobUrl}
|
||
controls
|
||
className="h-40 w-40 rounded-full object-cover"
|
||
/>
|
||
);
|
||
}
|
||
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>
|
||
);
|
||
}
|