update
This commit is contained in:
148
apps/frontend/components/chat/chat-image-lightbox.tsx
Normal file
148
apps/frontend/components/chat/chat-image-lightbox.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Download, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { ChatMessage } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ChatImageLightboxProps {
|
||||
open: boolean;
|
||||
images: ChatMessage[];
|
||||
index: number;
|
||||
mediaUrls: Record<string, { blobUrl: string; fileName?: string } | undefined>;
|
||||
onClose: () => void;
|
||||
onIndexChange: (index: number) => void;
|
||||
onDownload?: (message: ChatMessage) => void;
|
||||
}
|
||||
|
||||
export function ChatImageLightbox({
|
||||
open,
|
||||
images,
|
||||
index,
|
||||
mediaUrls,
|
||||
onClose,
|
||||
onIndexChange,
|
||||
onDownload
|
||||
}: ChatImageLightboxProps) {
|
||||
const current = images[index];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose();
|
||||
if (event.key === 'ArrowLeft') onIndexChange(Math.max(0, index - 1));
|
||||
if (event.key === 'ArrowRight') onIndexChange(Math.min(images.length - 1, index + 1));
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, index, images.length, onClose, onIndexChange]);
|
||||
|
||||
if (!open || !current?.storageKey) return null;
|
||||
|
||||
const media = mediaUrls[current.storageKey];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[250] flex flex-col bg-[#0f1117]/96">
|
||||
<div className="flex items-center justify-between px-4 py-3 text-white">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{current.senderName}</p>
|
||||
<p className="text-xs text-white/60">
|
||||
{index + 1} из {images.length}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{onDownload ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/10"
|
||||
aria-label="Скачать"
|
||||
onClick={() => onDownload(current)}
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-white hover:bg-white/10"
|
||||
aria-label="Закрыть"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-1 items-center justify-center px-3 pb-4">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'absolute left-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10',
|
||||
index <= 0 && 'pointer-events-none opacity-30'
|
||||
)}
|
||||
aria-label="Предыдущее"
|
||||
onClick={() => onIndexChange(Math.max(0, index - 1))}
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</Button>
|
||||
|
||||
<div className="flex max-h-full max-w-full items-center justify-center">
|
||||
{media?.blobUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={media.blobUrl} alt="" className="max-h-[calc(100vh-140px)] max-w-full object-contain" />
|
||||
) : (
|
||||
<div className="text-sm text-white/70">Загрузка изображения...</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'absolute right-3 z-10 h-11 w-11 rounded-full text-white hover:bg-white/10',
|
||||
index >= images.length - 1 && 'pointer-events-none opacity-30'
|
||||
)}
|
||||
aria-label="Следующее"
|
||||
onClick={() => onIndexChange(Math.min(images.length - 1, index + 1))}
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{current.content ? (
|
||||
<div className="border-t border-white/10 px-4 py-3 text-sm text-white/85">{current.content}</div>
|
||||
) : null}
|
||||
|
||||
{images.length > 1 ? (
|
||||
<div className="flex gap-2 overflow-x-auto px-4 pb-4">
|
||||
{images.map((image, imageIndex) => {
|
||||
const thumb = image.storageKey ? mediaUrls[image.storageKey] : undefined;
|
||||
return (
|
||||
<button
|
||||
key={image.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'h-14 w-14 shrink-0 overflow-hidden rounded-lg border-2',
|
||||
imageIndex === index ? 'border-[#3390ec]' : 'border-transparent opacity-70'
|
||||
)}
|
||||
onClick={() => onIndexChange(imageIndex)}
|
||||
>
|
||||
{thumb?.blobUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={thumb.blobUrl} alt="" className="h-full w-full object-cover" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
apps/frontend/components/chat/chat-media-album-grid.tsx
Normal file
84
apps/frontend/components/chat/chat-media-album-grid.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import type { ChatMessage } from '@/lib/api';
|
||||
import { parseChatMediaMetadata } from '@/lib/chat-media-album';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ChatMediaAlbumGridProps {
|
||||
messages: ChatMessage[];
|
||||
mediaUrls: Record<string, { blobUrl: string } | undefined>;
|
||||
className?: string;
|
||||
onImageClick?: (message: ChatMessage, index: number) => void;
|
||||
}
|
||||
|
||||
function tileClass(count: number, index: number) {
|
||||
if (count === 1) return 'col-span-2 row-span-2';
|
||||
if (count === 2) return 'col-span-1 row-span-2';
|
||||
if (count === 3) {
|
||||
if (index === 0) return 'col-span-1 row-span-2';
|
||||
return 'col-span-1 row-span-1';
|
||||
}
|
||||
if (count === 4) return 'col-span-1 row-span-1';
|
||||
if (count === 5) {
|
||||
if (index === 0) return 'col-span-2 row-span-2';
|
||||
return 'col-span-1 row-span-1';
|
||||
}
|
||||
if (count >= 6) {
|
||||
if (index === 0) return 'col-span-2 row-span-2';
|
||||
return 'col-span-1 row-span-1';
|
||||
}
|
||||
return 'col-span-1 row-span-1';
|
||||
}
|
||||
|
||||
export function ChatMediaAlbumGrid({ messages, mediaUrls, className, onImageClick }: ChatMediaAlbumGridProps) {
|
||||
const imageMessages = messages.filter((message) => message.type === 'IMAGE' && message.storageKey);
|
||||
const fileMessages = messages.filter((message) => message.type === 'FILE' && message.storageKey);
|
||||
const count = imageMessages.length;
|
||||
|
||||
if (!count && fileMessages.length) {
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{fileMessages.map((message) => (
|
||||
<div key={message.id} className="rounded-xl bg-black/5 px-3 py-2 text-sm">
|
||||
{message.content || parseChatMediaMetadata(message.metadataJson).fileName || 'Файл'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid max-w-[320px] grid-cols-2 gap-0.5 overflow-hidden rounded-2xl',
|
||||
count === 1 ? 'grid-rows-1' : count === 2 ? 'grid-rows-2' : 'grid-rows-3 auto-rows-[96px]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{imageMessages.map((message, index) => {
|
||||
const media = message.storageKey ? mediaUrls[message.storageKey] : undefined;
|
||||
const hiddenCount = count > 6 && index === 5 ? count - 6 : 0;
|
||||
return (
|
||||
<button
|
||||
key={message.id}
|
||||
type="button"
|
||||
className={cn('relative overflow-hidden bg-[#dfe4ea]', tileClass(Math.min(count, 6), index))}
|
||||
onClick={() => onImageClick?.(message, index)}
|
||||
>
|
||||
{media?.blobUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={media.blobUrl} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full min-h-[96px] items-center justify-center text-xs text-[#667085]">…</div>
|
||||
)}
|
||||
{hiddenCount > 0 ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/45 text-2xl font-semibold text-white">
|
||||
+{hiddenCount}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
366
apps/frontend/components/chat/chat-media-compose-dialog.tsx
Normal file
366
apps/frontend/components/chat/chat-media-compose-dialog.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Loader2, Plus, Smile, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { createFilePreviewUrl, isCompressibleImageFile, readImageDimensions } from '@/lib/chat-image-compress';
|
||||
import { formatFileSize } from '@/lib/chat-media';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ChatMediaComposeItem {
|
||||
id: string;
|
||||
file: File;
|
||||
previewUrl: string | null;
|
||||
sendAsFile: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const MAX_FILES = 10;
|
||||
|
||||
function createComposeItem(file: File, sendAsFile = false): ChatMediaComposeItem {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
previewUrl: createFilePreviewUrl(file),
|
||||
sendAsFile
|
||||
};
|
||||
}
|
||||
|
||||
export function useChatMediaComposer() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<ChatMediaComposeItem[]>([]);
|
||||
const [caption, setCaption] = useState('');
|
||||
const [sendAsFile, setSendAsFile] = useState(false);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setItems((current) => {
|
||||
current.forEach((item) => {
|
||||
if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
||||
});
|
||||
return [];
|
||||
});
|
||||
setCaption('');
|
||||
setSendAsFile(false);
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const openWithFiles = useCallback(async (files: File[], options?: { sendAsFile?: boolean }) => {
|
||||
const limited = files.slice(0, MAX_FILES);
|
||||
const initialSendAsFile = options?.sendAsFile ?? false;
|
||||
const nextItems = await Promise.all(
|
||||
limited.map(async (file) => {
|
||||
const item = createComposeItem(file, initialSendAsFile);
|
||||
if (isCompressibleImageFile(file)) {
|
||||
const dimensions = await readImageDimensions(file);
|
||||
item.width = dimensions.width;
|
||||
item.height = dimensions.height;
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
setItems(nextItems);
|
||||
setSendAsFile(initialSendAsFile);
|
||||
setCaption('');
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const addFiles = useCallback(async (files: File[]) => {
|
||||
const limited = files.slice(0, MAX_FILES);
|
||||
if (!limited.length) return;
|
||||
|
||||
const appended = await Promise.all(
|
||||
limited.map(async (file) => {
|
||||
const item = createComposeItem(file, sendAsFile);
|
||||
if (isCompressibleImageFile(file)) {
|
||||
const dimensions = await readImageDimensions(file);
|
||||
item.width = dimensions.width;
|
||||
item.height = dimensions.height;
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
|
||||
setItems((current) => [...current, ...appended].slice(0, MAX_FILES));
|
||||
}, [sendAsFile]);
|
||||
|
||||
const removeItem = useCallback((id: string) => {
|
||||
setItems((current) => {
|
||||
const target = current.find((item) => item.id === id);
|
||||
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
||||
const next = current.filter((item) => item.id !== id);
|
||||
if (!next.length) {
|
||||
setOpen(false);
|
||||
setCaption('');
|
||||
setSendAsFile(false);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setItems((current) => current.map((item) => ({ ...item, sendAsFile })));
|
||||
}, [sendAsFile]);
|
||||
|
||||
const hasImages = useMemo(() => items.some((item) => isCompressibleImageFile(item.file)), [items]);
|
||||
|
||||
return {
|
||||
open,
|
||||
items,
|
||||
caption,
|
||||
sendAsFile,
|
||||
hasImages,
|
||||
maxFiles: MAX_FILES,
|
||||
setCaption,
|
||||
setSendAsFile,
|
||||
openWithFiles,
|
||||
addFiles,
|
||||
removeItem,
|
||||
close
|
||||
};
|
||||
}
|
||||
|
||||
interface ChatMediaComposeDialogProps {
|
||||
open: boolean;
|
||||
items: ChatMediaComposeItem[];
|
||||
caption: string;
|
||||
sendAsFile: boolean;
|
||||
hasImages: boolean;
|
||||
maxFiles: number;
|
||||
sending?: boolean;
|
||||
onCaptionChange: (value: string) => void;
|
||||
onSendAsFileChange: (value: boolean) => void;
|
||||
onRemoveItem: (id: string) => void;
|
||||
onAddFiles: (files: File[]) => void;
|
||||
onClose: () => void;
|
||||
onSend: () => void;
|
||||
}
|
||||
|
||||
export function ChatMediaComposeDialog({
|
||||
open,
|
||||
items,
|
||||
caption,
|
||||
sendAsFile,
|
||||
hasImages,
|
||||
maxFiles,
|
||||
sending = false,
|
||||
onCaptionChange,
|
||||
onSendAsFileChange,
|
||||
onRemoveItem,
|
||||
onAddFiles,
|
||||
onClose,
|
||||
onSend
|
||||
}: ChatMediaComposeDialogProps) {
|
||||
const addInputRef = useRef<HTMLInputElement>(null);
|
||||
const activePreview = items[0] ?? null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="overflow-hidden rounded-[20px] p-0 sm:max-w-[460px]">
|
||||
<div className="border-b border-[#eceef4] px-5 py-4">
|
||||
<DialogHeader className="mb-0">
|
||||
<DialogTitle>{items.length > 1 ? `Отправить ${items.length} файла` : 'Отправить изображение'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
{activePreview ? (
|
||||
<div className="relative overflow-hidden rounded-2xl bg-[#0f1117]">
|
||||
{activePreview.previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={activePreview.previewUrl}
|
||||
alt={activePreview.file.name}
|
||||
className="mx-auto max-h-[320px] w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-h-[180px] flex-col items-center justify-center gap-2 px-6 text-center text-white/80">
|
||||
<p className="text-sm font-medium">{activePreview.file.name}</p>
|
||||
<p className="text-xs text-white/55">{formatFileSize(activePreview.file.size)}</p>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-3 rounded-full bg-black/55 p-1.5 text-white transition hover:bg-black/70"
|
||||
aria-label="Удалить файл"
|
||||
onClick={() => onRemoveItem(activePreview.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{items.length > 1 ? (
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="group relative aspect-square overflow-hidden rounded-xl bg-[#eef1f6]"
|
||||
onClick={() => onRemoveItem(item.id)}
|
||||
title="Нажмите, чтобы удалить"
|
||||
>
|
||||
{item.previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={item.previewUrl} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center px-1 text-[10px] text-[#667085]">{item.file.name.slice(0, 8)}</div>
|
||||
)}
|
||||
<span className="absolute inset-0 bg-black/0 transition group-hover:bg-black/20" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : activePreview?.previewUrl ? (
|
||||
<p className="text-center text-xs text-[#667085]">Для редактирования нажмите на изображение</p>
|
||||
) : null}
|
||||
|
||||
{hasImages ? (
|
||||
<label className="flex cursor-pointer items-center gap-3 rounded-xl bg-[#f4f5f8] px-3 py-2.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sendAsFile}
|
||||
onChange={(event) => onSendAsFileChange(event.target.checked)}
|
||||
className="h-4 w-4 rounded border-[#cfd5df]"
|
||||
/>
|
||||
<span>Отправить как файл</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={caption}
|
||||
onChange={(event) => onCaptionChange(event.target.value)}
|
||||
placeholder="Подпись"
|
||||
className="rounded-xl pr-10"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Smile className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#98a2b3]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-[#eceef4] px-5 py-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-[#3390ec]"
|
||||
disabled={items.length >= maxFiles || sending}
|
||||
onClick={() => addInputRef.current?.click()}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Добавить
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="ghost" disabled={sending} onClick={onClose}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="button" disabled={!items.length || sending} onClick={onSend}>
|
||||
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Отправить'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={addInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.7z,.txt"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length) onAddFiles(files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChatMediaDropOverlayProps {
|
||||
visible: boolean;
|
||||
onDropCompressed: (files: File[]) => void;
|
||||
onDropAsFile: (files: File[]) => void;
|
||||
}
|
||||
|
||||
export function ChatMediaDropOverlay({ visible, onDropCompressed, onDropAsFile }: ChatMediaDropOverlayProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-30 flex flex-col gap-3 bg-[#0f1117]/78 p-4 backdrop-blur-[1px]">
|
||||
<DropZone
|
||||
title="Перетащите сюда фотографии"
|
||||
subtitle="для отправки их без сжатия"
|
||||
className="flex-1 border-white/20 bg-white/5 text-white/90"
|
||||
onDrop={onDropAsFile}
|
||||
/>
|
||||
<DropZone
|
||||
title="Перетащите сюда фотографии"
|
||||
subtitle="для быстрой отправки"
|
||||
className="flex-1 border-[#3390ec]/40 bg-[#3390ec]/10 text-[#dbeafe]"
|
||||
onDrop={onDropCompressed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DropZone({
|
||||
title,
|
||||
subtitle,
|
||||
className,
|
||||
onDrop
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
className?: string;
|
||||
onDrop: (files: File[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-auto flex flex-col items-center justify-center rounded-[24px] border-2 border-dashed px-6 text-center transition',
|
||||
className
|
||||
)}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const files = Array.from(event.dataTransfer.files ?? []);
|
||||
if (files.length) onDrop(files);
|
||||
}}
|
||||
>
|
||||
<p className="text-lg font-medium">{title}</p>
|
||||
<p className="mt-1 text-sm opacity-80">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function extractFilesFromDataTransfer(dataTransfer: DataTransfer | null) {
|
||||
if (!dataTransfer) return [] as File[];
|
||||
return Array.from(dataTransfer.files ?? []);
|
||||
}
|
||||
|
||||
export function extractFilesFromClipboard(dataTransfer: DataTransfer | null) {
|
||||
if (!dataTransfer) return [] as File[];
|
||||
const files: File[] = [];
|
||||
for (const item of Array.from(dataTransfer.items ?? [])) {
|
||||
if (item.kind !== 'file') continue;
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
if (files.length) return files;
|
||||
return Array.from(dataTransfer.files ?? []);
|
||||
}
|
||||
@@ -135,6 +135,16 @@ export function FamilyBotChat({
|
||||
return;
|
||||
}
|
||||
|
||||
const eventScope = payload.scope === 'broadcast' ? 'broadcast' : 'private';
|
||||
const eventRoomId = typeof payload.roomId === 'string' ? payload.roomId : null;
|
||||
if (eventScope === 'broadcast') {
|
||||
if (roomId && eventRoomId && eventRoomId !== roomId) {
|
||||
return;
|
||||
}
|
||||
} else if (roomId) {
|
||||
// Личные сообщения бота не транслируются другим участникам комнаты.
|
||||
}
|
||||
|
||||
if (event.type === 'bot_message') {
|
||||
const messageId = Number(payload.messageId);
|
||||
if (!Number.isFinite(messageId)) {
|
||||
@@ -144,8 +154,9 @@ export function FamilyBotChat({
|
||||
|
||||
const replyMarkupJson = markupJsonFromPayload(payload);
|
||||
const outbound: BotChatMessage = {
|
||||
id: `out-ws-${messageId}`,
|
||||
id: `${eventScope === 'broadcast' ? 'broadcast' : 'out'}-ws-${messageId}`,
|
||||
direction: 'out',
|
||||
scope: eventScope,
|
||||
text: typeof payload.text === 'string' ? payload.text : '',
|
||||
messageType: typeof payload.messageType === 'string' ? payload.messageType : 'text',
|
||||
messageId,
|
||||
@@ -156,7 +167,9 @@ export function FamilyBotChat({
|
||||
};
|
||||
|
||||
setMessages((current) => {
|
||||
const withoutDuplicate = current.filter((item) => item.messageId !== messageId || item.direction !== 'out');
|
||||
const withoutDuplicate = current.filter(
|
||||
(item) => item.messageId !== messageId || item.direction !== 'out' || item.scope !== eventScope
|
||||
);
|
||||
return [...withoutDuplicate, outbound].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
});
|
||||
return;
|
||||
@@ -191,7 +204,7 @@ export function FamilyBotChat({
|
||||
const replyMarkupJson = markupJsonFromPayload(payload);
|
||||
setMessages((current) =>
|
||||
current.map((item) =>
|
||||
item.messageId === messageId && item.direction === 'out'
|
||||
item.messageId === messageId && item.direction === 'out' && (item.scope ?? 'private') === eventScope
|
||||
? {
|
||||
...item,
|
||||
text: typeof payload.text === 'string' ? payload.text : item.text,
|
||||
@@ -212,7 +225,7 @@ export function FamilyBotChat({
|
||||
|
||||
setMessages((current) =>
|
||||
current.map((item) =>
|
||||
item.messageId === messageId && item.direction === 'out'
|
||||
item.messageId === messageId && item.direction === 'out' && (item.scope ?? 'private') === eventScope
|
||||
? {
|
||||
...item,
|
||||
isPinned: Boolean(payload.isPinned),
|
||||
@@ -223,7 +236,7 @@ export function FamilyBotChat({
|
||||
);
|
||||
}
|
||||
});
|
||||
}, [botRef, loadMessages, subscribe]);
|
||||
}, [botRef, loadMessages, roomId, subscribe]);
|
||||
|
||||
async function handleSend() {
|
||||
if (!token || !draft.trim() || sending) return;
|
||||
@@ -271,12 +284,18 @@ export function FamilyBotChat({
|
||||
) : messages.length ? (
|
||||
messages.map((message) => {
|
||||
const mine = message.direction === 'in';
|
||||
const isBroadcast = message.scope === 'broadcast';
|
||||
const hasKeyboard = Boolean(message.replyMarkupJson?.trim());
|
||||
return (
|
||||
<div key={message.id} id={`bot-msg-${message.id}`} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
|
||||
<BotChatMessageContextMenu text={message.text} onCopy={() => showToast('Текст скопирован')}>
|
||||
<div className={cn('max-w-[78%] rounded-[18px] px-3 py-2 shadow-sm', mine ? 'rounded-br-md bg-[#effdde]' : 'rounded-bl-md bg-white')}>
|
||||
{!mine ? <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{botName}</p> : null}
|
||||
{!mine ? (
|
||||
<p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">
|
||||
{botName}
|
||||
{isBroadcast ? ' · уведомление' : ''}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="whitespace-pre-wrap text-sm">{message.text || '…'}</p>
|
||||
{!mine && hasKeyboard && message.messageId > 0 ? (
|
||||
<BotMessageKeyboard
|
||||
|
||||
@@ -34,6 +34,15 @@ import { BotMiniAppSheet, FamilyBotChat } from '@/components/family/family-bot-c
|
||||
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
|
||||
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
|
||||
import { ChatForwardDialog } from '@/components/chat/chat-forward-dialog';
|
||||
import { ChatImageLightbox } from '@/components/chat/chat-image-lightbox';
|
||||
import { ChatMediaAlbumGrid } from '@/components/chat/chat-media-album-grid';
|
||||
import {
|
||||
ChatMediaComposeDialog,
|
||||
ChatMediaDropOverlay,
|
||||
extractFilesFromClipboard,
|
||||
extractFilesFromDataTransfer,
|
||||
useChatMediaComposer
|
||||
} from '@/components/chat/chat-media-compose-dialog';
|
||||
import { ChatDateSeparator } from '@/components/chat/chat-date-separator';
|
||||
import { ChatToolsDialog, type ChatToolsTab } from '@/components/chat/chat-tools-dialog';
|
||||
import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
|
||||
@@ -126,6 +135,8 @@ import {
|
||||
parseMessageMetadata,
|
||||
resolveChatContentType
|
||||
} from '@/lib/chat-media';
|
||||
import { collectChatImageMessages, parseChatMediaMetadata } from '@/lib/chat-media-album';
|
||||
import { uploadChatMediaBatch } from '@/lib/chat-media-upload';
|
||||
|
||||
interface PresignedUploadResponse {
|
||||
uploadUrl: string;
|
||||
@@ -211,6 +222,31 @@ function familyMemberRoleLabel(role: string) {
|
||||
return role === 'owner' ? 'Создатель семьи' : 'Участник';
|
||||
}
|
||||
|
||||
function shouldSkipGroupedAlbumMessage(messages: ChatMessage[], index: number) {
|
||||
const message = messages[index];
|
||||
if (!message) return false;
|
||||
const meta = parseChatMediaMetadata(message.metadataJson);
|
||||
if (!meta.albumId) return false;
|
||||
const previous = messages[index - 1];
|
||||
if (!previous) return false;
|
||||
const previousMeta = parseChatMediaMetadata(previous.metadataJson);
|
||||
return previous.senderId === message.senderId && previousMeta.albumId === meta.albumId;
|
||||
}
|
||||
|
||||
function collectAlbumMessages(messages: ChatMessage[], startIndex: number) {
|
||||
const first = messages[startIndex]!;
|
||||
const albumId = parseChatMediaMetadata(first.metadataJson).albumId;
|
||||
if (!albumId) return [first];
|
||||
const result: ChatMessage[] = [];
|
||||
for (let index = startIndex; index < messages.length; index += 1) {
|
||||
const message = messages[index]!;
|
||||
const meta = parseChatMediaMetadata(message.metadataJson);
|
||||
if (message.senderId !== first.senderId || meta.albumId !== albumId) break;
|
||||
result.push(message);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const router = useRouter();
|
||||
const { user, token } = useAuth();
|
||||
@@ -258,6 +294,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const [replyToMessage, setReplyToMessage] = useState<ChatMessage | null>(null);
|
||||
const [chatToolsOpen, setChatToolsOpen] = useState(false);
|
||||
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
||||
const [mediaDropVisible, setMediaDropVisible] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
const mediaComposer = useChatMediaComposer();
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
|
||||
@@ -268,6 +308,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const recordingStartedAtRef = useRef<number | null>(null);
|
||||
const mediaUrlsRef = useRef<Record<string, LoadedChatMedia>>({});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
mediaUrlsRef.current = mediaUrls;
|
||||
@@ -279,6 +320,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}, []);
|
||||
|
||||
const visibleMessages = useMemo(() => messages.filter((message) => !message.isDeleted), [messages]);
|
||||
const chatImageMessages = useMemo(() => collectChatImageMessages(visibleMessages), [visibleMessages]);
|
||||
const messageById = useMemo(
|
||||
() => new Map(visibleMessages.map((message) => [message.id, message])),
|
||||
[visibleMessages]
|
||||
@@ -1127,6 +1169,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
|
||||
async function uploadChatFile(file: File, options?: { voice?: boolean; durationMs?: number }) {
|
||||
if (!token || !activeRoomId || !activeRoom) return;
|
||||
if (!options?.voice) {
|
||||
queueMediaFiles([file]);
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
const contentType = resolveChatContentType(file);
|
||||
@@ -1191,6 +1237,68 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
const queueMediaFiles = useCallback(
|
||||
(files: File[], options?: { sendAsFile?: boolean }) => {
|
||||
if (!activeRoom || activeRoomIsBot || !files.length) return;
|
||||
if (files.length > mediaComposer.maxFiles) {
|
||||
showToast(`Можно отправить не более ${mediaComposer.maxFiles} файлов за раз`);
|
||||
}
|
||||
const limited = files.slice(0, mediaComposer.maxFiles);
|
||||
if (mediaComposer.open && mediaComposer.items.length) {
|
||||
void mediaComposer.addFiles(limited);
|
||||
return;
|
||||
}
|
||||
void mediaComposer.openWithFiles(limited, options);
|
||||
},
|
||||
[activeRoom, activeRoomIsBot, mediaComposer]
|
||||
);
|
||||
|
||||
async function submitMediaCompose() {
|
||||
if (!token || !activeRoomId || !activeRoom || !mediaComposer.items.length) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const uploaded = await uploadChatMediaBatch({
|
||||
roomId: activeRoomId,
|
||||
token,
|
||||
items: mediaComposer.items.map((item) => ({
|
||||
file: item.file,
|
||||
sendAsFile: item.sendAsFile,
|
||||
width: item.width,
|
||||
height: item.height
|
||||
})),
|
||||
caption: mediaComposer.caption,
|
||||
isE2E: activeRoomIsE2E,
|
||||
userId: user?.id,
|
||||
peerE2eKey,
|
||||
encryptOutgoing
|
||||
});
|
||||
uploaded.forEach((message) => appendMessage(message));
|
||||
mediaComposer.close();
|
||||
void loadGroup();
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось отправить файлы') ?? 'Ошибка');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRoom || activeRoomIsBot) return;
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
const files = extractFilesFromClipboard(event.clipboardData);
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
queueMediaFiles(files, { sendAsFile: false });
|
||||
}
|
||||
window.addEventListener('paste', handlePaste);
|
||||
return () => window.removeEventListener('paste', handlePaste);
|
||||
}, [activeRoom, activeRoomIsBot, queueMediaFiles]);
|
||||
|
||||
function openImageLightbox(message: ChatMessage) {
|
||||
const index = chatImageMessages.findIndex((item) => item.id === message.id);
|
||||
if (index >= 0) setLightboxIndex(index);
|
||||
}
|
||||
|
||||
async function submitPoll() {
|
||||
if (!token || !activeRoomId) return;
|
||||
const options = pollOptions.map((item) => item.trim()).filter(Boolean);
|
||||
@@ -1526,7 +1634,46 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="relative flex min-h-0 flex-1 flex-col"
|
||||
onDragEnter={(event) => {
|
||||
if (activeRoomIsBot) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setMediaDropVisible(true);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (activeRoomIsBot) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setMediaDropVisible(false);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (activeRoomIsBot) return;
|
||||
event.preventDefault();
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (activeRoomIsBot) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setMediaDropVisible(false);
|
||||
const files = extractFilesFromDataTransfer(event.dataTransfer);
|
||||
if (files.length) queueMediaFiles(files, { sendAsFile: false });
|
||||
}}
|
||||
>
|
||||
<ChatMediaDropOverlay
|
||||
visible={mediaDropVisible && !activeRoomIsBot}
|
||||
onDropAsFile={(files) => {
|
||||
dragDepthRef.current = 0;
|
||||
setMediaDropVisible(false);
|
||||
queueMediaFiles(files, { sendAsFile: true });
|
||||
}}
|
||||
onDropCompressed={(files) => {
|
||||
dragDepthRef.current = 0;
|
||||
setMediaDropVisible(false);
|
||||
queueMediaFiles(files, { sendAsFile: false });
|
||||
}}
|
||||
/>
|
||||
{pinnedMessage ? (
|
||||
<ChatPinnedMessageBanner
|
||||
senderName={pinnedMessage.senderName}
|
||||
@@ -1540,6 +1687,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
) : null}
|
||||
<div className={cn('flex-1 space-y-2 overflow-y-auto px-4 py-4', isDragging && 'select-none')}>
|
||||
{visibleMessages.map((message, messageIndex) => {
|
||||
if (shouldSkipGroupedAlbumMessage(visibleMessages, messageIndex)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousMessage = visibleMessages[messageIndex - 1];
|
||||
const showDateSeparator = shouldShowChatDateSeparator(message.createdAt, previousMessage?.createdAt);
|
||||
|
||||
@@ -1591,6 +1742,8 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const largeEmojiNative = isLargeEmoji
|
||||
? getSingleEmojiNative({ type: message.type, content: emojiContent ?? message.content, visibleText })
|
||||
: '';
|
||||
const albumMessages = collectAlbumMessages(visibleMessages, messageIndex);
|
||||
const isAlbumGroup = albumMessages.length > 1 && Boolean(parseChatMediaMetadata(message.metadataJson).albumId);
|
||||
const bubble = (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1642,6 +1795,22 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
</div>
|
||||
) : message.storageKey ? (
|
||||
(() => {
|
||||
if (isAlbumGroup) {
|
||||
const captionMessage = albumMessages.find((item) => item.content?.trim());
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<ChatMediaAlbumGrid
|
||||
messages={albumMessages}
|
||||
mediaUrls={mediaUrls}
|
||||
onImageClick={(item) => openImageLightbox(item)}
|
||||
/>
|
||||
{captionMessage?.content ? (
|
||||
<p className="whitespace-pre-wrap text-sm">{captionMessage.content}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const media = mediaUrls[message.storageKey];
|
||||
const meta = parseMessageMetadata(message.metadataJson);
|
||||
const fileName = e2ePayload?.fileName || meta.fileName || message.content || 'Файл';
|
||||
@@ -1655,8 +1824,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}
|
||||
if (message.type === 'IMAGE') {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={media.blobUrl} alt={fileName} className="max-h-72 max-w-full rounded-xl object-cover" />
|
||||
<button type="button" className="block overflow-hidden rounded-xl" onClick={() => openImageLightbox(message)}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={media.blobUrl} alt={fileName} className="max-h-72 max-w-full object-cover" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (message.type === 'VOICE' || message.type === 'AUDIO') {
|
||||
@@ -1933,7 +2104,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
@@ -1941,11 +2112,44 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<input ref={fileInputRef} type="file" className="hidden" onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void uploadChatFile(file);
|
||||
event.target.value = '';
|
||||
}} />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.7z,.txt"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length) queueMediaFiles(files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChatMediaComposeDialog
|
||||
open={mediaComposer.open}
|
||||
items={mediaComposer.items}
|
||||
caption={mediaComposer.caption}
|
||||
sendAsFile={mediaComposer.sendAsFile}
|
||||
hasImages={mediaComposer.hasImages}
|
||||
maxFiles={mediaComposer.maxFiles}
|
||||
sending={sending}
|
||||
onCaptionChange={mediaComposer.setCaption}
|
||||
onSendAsFileChange={mediaComposer.setSendAsFile}
|
||||
onRemoveItem={mediaComposer.removeItem}
|
||||
onAddFiles={(files) => void mediaComposer.addFiles(files)}
|
||||
onClose={mediaComposer.close}
|
||||
onSend={() => void submitMediaCompose()}
|
||||
/>
|
||||
|
||||
<ChatImageLightbox
|
||||
open={lightboxIndex !== null}
|
||||
images={chatImageMessages}
|
||||
index={lightboxIndex ?? 0}
|
||||
mediaUrls={mediaUrls}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onIndexChange={setLightboxIndex}
|
||||
onDownload={(message) => void downloadChatFile(message)}
|
||||
/>
|
||||
|
||||
<FamilyInviteDialog
|
||||
groupId={groupId}
|
||||
|
||||
@@ -7,6 +7,13 @@ import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
|
||||
import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat';
|
||||
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
|
||||
import { ChatDateSeparator } from '@/components/chat/chat-date-separator';
|
||||
import {
|
||||
ChatMediaComposeDialog,
|
||||
ChatMediaDropOverlay,
|
||||
extractFilesFromClipboard,
|
||||
extractFilesFromDataTransfer,
|
||||
useChatMediaComposer
|
||||
} from '@/components/chat/chat-media-compose-dialog';
|
||||
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
|
||||
import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
|
||||
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
|
||||
@@ -58,6 +65,7 @@ import {
|
||||
isSystemMessage
|
||||
} from '@/lib/chat-message-utils';
|
||||
import { detectChatMessageType, parseMessageMetadata, resolveChatContentType } from '@/lib/chat-media';
|
||||
import { uploadChatMediaBatch } from '@/lib/chat-media-upload';
|
||||
import { getSingleEmojiNative, isSingleEmojiMessage } from '@/lib/chat-emoji-display';
|
||||
import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils';
|
||||
import { encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat';
|
||||
@@ -122,9 +130,12 @@ export function MiniFamilyChat() {
|
||||
const [pollOptions, setPollOptions] = useState(['', '']);
|
||||
const [chatToolsOpen, setChatToolsOpen] = useState(false);
|
||||
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
||||
const [mediaDropVisible, setMediaDropVisible] = useState(false);
|
||||
const mediaComposer = useChatMediaComposer();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
const previousGroupIdRef = useRef<string | null>(null);
|
||||
|
||||
const visibleMessages = useMemo(
|
||||
@@ -580,65 +591,66 @@ export function MiniFamilyChat() {
|
||||
}
|
||||
|
||||
async function uploadChatFile(file: File) {
|
||||
if (!token || !activeRoom || sending) return;
|
||||
queueMediaFiles([file]);
|
||||
}
|
||||
|
||||
const queueMediaFiles = useCallback(
|
||||
(files: File[], options?: { sendAsFile?: boolean }) => {
|
||||
if (!activeRoom || view !== 'chat' || !files.length) return;
|
||||
if (files.length > mediaComposer.maxFiles) {
|
||||
showToast(`Можно отправить не более ${mediaComposer.maxFiles} файлов за раз`);
|
||||
}
|
||||
const limited = files.slice(0, mediaComposer.maxFiles);
|
||||
if (mediaComposer.open && mediaComposer.items.length) {
|
||||
void mediaComposer.addFiles(limited);
|
||||
return;
|
||||
}
|
||||
void mediaComposer.openWithFiles(limited, options);
|
||||
},
|
||||
[activeRoom, mediaComposer, view]
|
||||
);
|
||||
|
||||
async function submitMediaCompose() {
|
||||
if (!token || !activeRoom || !mediaComposer.items.length) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const contentType = resolveChatContentType(file);
|
||||
const messageType = detectChatMessageType(file);
|
||||
const metadata = { fileName: file.name, fileSize: file.size };
|
||||
|
||||
let uploadFile = file;
|
||||
let uploadContentType = contentType;
|
||||
let messageContent: string | undefined = messageType === 'FILE' ? file.name : undefined;
|
||||
let isEncrypted = false;
|
||||
|
||||
if (activeRoomIsE2E) {
|
||||
if (!user?.id || !peerE2eKey) {
|
||||
showToast('Секретный чат недоступен без ключей E2E у обоих участников');
|
||||
return;
|
||||
}
|
||||
const encryptedBuffer = await encryptE2EBlob(user.id, peerE2eKey, await file.arrayBuffer());
|
||||
uploadFile = new File([encryptedBuffer], `${file.name}.lendryenc`, { type: 'application/octet-stream' });
|
||||
uploadContentType = 'application/octet-stream';
|
||||
messageContent = await encryptOutgoing({
|
||||
kind: e2eKindFromMessageType(messageType),
|
||||
fileName: file.name,
|
||||
mimeType: contentType,
|
||||
fileSize: file.size
|
||||
});
|
||||
isEncrypted = true;
|
||||
}
|
||||
|
||||
const presigned = await apiFetch<PresignedUploadResponse>(
|
||||
`/media/chat/${activeRoom.id}/media/upload-url`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contentType: uploadContentType, fileName: uploadFile.name })
|
||||
},
|
||||
token
|
||||
);
|
||||
await uploadMediaObject(presigned, uploadFile, uploadContentType);
|
||||
const message = await sendChatMessage(
|
||||
activeRoom.id,
|
||||
{
|
||||
type: messageType,
|
||||
storageKey: presigned.storageKey,
|
||||
mimeType: uploadContentType,
|
||||
content: messageContent,
|
||||
isEncrypted,
|
||||
metadataJson: JSON.stringify(activeRoomIsE2E ? { e2e: true, ...metadata } : metadata)
|
||||
},
|
||||
token
|
||||
);
|
||||
setMessages((current) => [...current, message]);
|
||||
const uploaded = await uploadChatMediaBatch({
|
||||
roomId: activeRoom.id,
|
||||
token,
|
||||
items: mediaComposer.items.map((item) => ({
|
||||
file: item.file,
|
||||
sendAsFile: item.sendAsFile,
|
||||
width: item.width,
|
||||
height: item.height
|
||||
})),
|
||||
caption: mediaComposer.caption,
|
||||
isE2E: activeRoomIsE2E,
|
||||
userId: user?.id,
|
||||
peerE2eKey,
|
||||
encryptOutgoing
|
||||
});
|
||||
setMessages((current) => [...current, ...uploaded]);
|
||||
mediaComposer.close();
|
||||
void loadRooms();
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось отправить файл') ?? 'Ошибка');
|
||||
showToast(getApiErrorMessage(error, 'Не удалось отправить файлы') ?? 'Ошибка');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRoom || view !== 'chat') return;
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
const files = extractFilesFromClipboard(event.clipboardData);
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
queueMediaFiles(files, { sendAsFile: false });
|
||||
}
|
||||
window.addEventListener('paste', handlePaste);
|
||||
return () => window.removeEventListener('paste', handlePaste);
|
||||
}, [activeRoom, queueMediaFiles, view]);
|
||||
|
||||
async function submitPoll() {
|
||||
if (!token || !activeRoom) return;
|
||||
const options = pollOptions.map((item) => item.trim()).filter(Boolean);
|
||||
@@ -946,7 +958,40 @@ export function MiniFamilyChat() {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="relative flex min-h-0 flex-1 flex-col"
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setMediaDropVisible(true);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setMediaDropVisible(false);
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setMediaDropVisible(false);
|
||||
const files = extractFilesFromDataTransfer(event.dataTransfer);
|
||||
if (files.length) queueMediaFiles(files, { sendAsFile: false });
|
||||
}}
|
||||
>
|
||||
<ChatMediaDropOverlay
|
||||
visible={mediaDropVisible}
|
||||
onDropAsFile={(files) => {
|
||||
dragDepthRef.current = 0;
|
||||
setMediaDropVisible(false);
|
||||
queueMediaFiles(files, { sendAsFile: true });
|
||||
}}
|
||||
onDropCompressed={(files) => {
|
||||
dragDepthRef.current = 0;
|
||||
setMediaDropVisible(false);
|
||||
queueMediaFiles(files, { sendAsFile: false });
|
||||
}}
|
||||
/>
|
||||
{pinnedMessage ? (
|
||||
<ChatPinnedMessageBanner
|
||||
senderName={pinnedMessage.senderName}
|
||||
@@ -1268,7 +1313,7 @@ export function MiniFamilyChat() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1335,14 +1380,30 @@ export function MiniFamilyChat() {
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,audio/*,video/*,.pdf,.doc,.docx,.txt,.zip"
|
||||
multiple
|
||||
accept="image/*,audio/*,video/*,.pdf,.doc,.docx,.txt,.zip,.rar,.7z"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void uploadChatFile(file);
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length) queueMediaFiles(files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<ChatMediaComposeDialog
|
||||
open={mediaComposer.open}
|
||||
items={mediaComposer.items}
|
||||
caption={mediaComposer.caption}
|
||||
sendAsFile={mediaComposer.sendAsFile}
|
||||
hasImages={mediaComposer.hasImages}
|
||||
maxFiles={mediaComposer.maxFiles}
|
||||
sending={sending}
|
||||
onCaptionChange={mediaComposer.setCaption}
|
||||
onSendAsFileChange={mediaComposer.setSendAsFile}
|
||||
onRemoveItem={mediaComposer.removeItem}
|
||||
onAddFiles={(files) => void mediaComposer.addFiles(files)}
|
||||
onClose={mediaComposer.close}
|
||||
onSend={() => void submitMediaCompose()}
|
||||
/>
|
||||
<BotMiniAppSheet
|
||||
url={miniAppUrl}
|
||||
title={miniAppTitle}
|
||||
|
||||
@@ -747,6 +747,7 @@ export interface FamilyGroup {
|
||||
export interface BotChatMessage {
|
||||
id: string;
|
||||
direction: 'in' | 'out';
|
||||
scope?: 'private' | 'broadcast';
|
||||
text: string;
|
||||
messageType: string;
|
||||
messageId: number;
|
||||
|
||||
114
apps/frontend/lib/chat-image-compress.ts
Normal file
114
apps/frontend/lib/chat-image-compress.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
const DEFAULT_MAX_EDGE = 2560;
|
||||
const DEFAULT_QUALITY = 0.82;
|
||||
|
||||
function loadImageFromFile(file: File) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Не удалось прочитать изображение'));
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number) {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Не удалось сжать изображение'));
|
||||
return;
|
||||
}
|
||||
resolve(blob);
|
||||
},
|
||||
type,
|
||||
quality
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isCompressibleImageFile(file: File) {
|
||||
const type = (file.type || '').toLowerCase();
|
||||
if (type.startsWith('image/') && !type.includes('gif') && !type.includes('svg')) {
|
||||
return true;
|
||||
}
|
||||
return /\.(jpe?g|png|webp|heic|heif|bmp)$/i.test(file.name);
|
||||
}
|
||||
|
||||
export async function compressChatImage(
|
||||
file: File,
|
||||
options?: { maxEdge?: number; quality?: number }
|
||||
) {
|
||||
const maxEdge = options?.maxEdge ?? DEFAULT_MAX_EDGE;
|
||||
const quality = options?.quality ?? DEFAULT_QUALITY;
|
||||
|
||||
if (!isCompressibleImageFile(file)) {
|
||||
return { file, width: undefined, height: undefined, compressed: false };
|
||||
}
|
||||
|
||||
const image = await loadImageFromFile(file);
|
||||
const width = image.naturalWidth || image.width;
|
||||
const height = image.naturalHeight || image.height;
|
||||
if (!width || !height) {
|
||||
return { file, width: undefined, height: undefined, compressed: false };
|
||||
}
|
||||
|
||||
const scale = Math.min(1, maxEdge / Math.max(width, height));
|
||||
const targetWidth = Math.max(1, Math.round(width * scale));
|
||||
const targetHeight = Math.max(1, Math.round(height * scale));
|
||||
|
||||
if (scale >= 1 && file.size <= 350_000) {
|
||||
return { file, width, height, compressed: false };
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = targetWidth;
|
||||
canvas.height = targetHeight;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return { file, width, height, compressed: false };
|
||||
}
|
||||
|
||||
context.drawImage(image, 0, 0, targetWidth, targetHeight);
|
||||
|
||||
const outputType = file.type === 'image/png' ? 'image/jpeg' : file.type || 'image/jpeg';
|
||||
const blob = await canvasToBlob(canvas, outputType, quality);
|
||||
const extension = outputType.includes('webp') ? 'webp' : 'jpg';
|
||||
const baseName = file.name.replace(/\.[^.]+$/, '') || 'image';
|
||||
const compressedFile = new File([blob], `${baseName}.${extension}`, { type: outputType, lastModified: Date.now() });
|
||||
|
||||
return {
|
||||
file: compressedFile,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
compressed: true
|
||||
};
|
||||
}
|
||||
|
||||
export async function readImageDimensions(file: File) {
|
||||
if (!isCompressibleImageFile(file)) {
|
||||
return { width: undefined, height: undefined };
|
||||
}
|
||||
try {
|
||||
const image = await loadImageFromFile(file);
|
||||
return {
|
||||
width: image.naturalWidth || image.width,
|
||||
height: image.naturalHeight || image.height
|
||||
};
|
||||
} catch {
|
||||
return { width: undefined, height: undefined };
|
||||
}
|
||||
}
|
||||
|
||||
export function createFilePreviewUrl(file: File) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
return URL.createObjectURL(file);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
75
apps/frontend/lib/chat-media-album.ts
Normal file
75
apps/frontend/lib/chat-media-album.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { ChatMessage } from '@/lib/api';
|
||||
|
||||
export interface ChatMediaMetadata {
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
durationMs?: number;
|
||||
albumId?: string;
|
||||
albumIndex?: number;
|
||||
albumCount?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
sendAsFile?: boolean;
|
||||
e2e?: boolean;
|
||||
}
|
||||
|
||||
export function parseChatMediaMetadata(metadataJson?: string): ChatMediaMetadata {
|
||||
if (!metadataJson) return {};
|
||||
try {
|
||||
return JSON.parse(metadataJson) as ChatMediaMetadata;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export type ChatDisplayItem =
|
||||
| { kind: 'message'; message: ChatMessage }
|
||||
| { kind: 'album'; albumId: string; messages: ChatMessage[] };
|
||||
|
||||
export function groupChatMessagesForDisplay(messages: ChatMessage[]): ChatDisplayItem[] {
|
||||
const items: ChatDisplayItem[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < messages.length) {
|
||||
const message = messages[index]!;
|
||||
const meta = parseChatMediaMetadata(message.metadataJson);
|
||||
|
||||
if (meta.albumId && message.storageKey) {
|
||||
const albumMessages: ChatMessage[] = [message];
|
||||
let cursor = index + 1;
|
||||
while (cursor < messages.length) {
|
||||
const next = messages[cursor]!;
|
||||
const nextMeta = parseChatMediaMetadata(next.metadataJson);
|
||||
if (
|
||||
nextMeta.albumId === meta.albumId &&
|
||||
next.senderId === message.senderId &&
|
||||
next.storageKey
|
||||
) {
|
||||
albumMessages.push(next);
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
albumMessages.sort((left, right) => {
|
||||
const leftIndex = parseChatMediaMetadata(left.metadataJson).albumIndex ?? 0;
|
||||
const rightIndex = parseChatMediaMetadata(right.metadataJson).albumIndex ?? 0;
|
||||
return leftIndex - rightIndex;
|
||||
});
|
||||
|
||||
items.push({ kind: 'album', albumId: meta.albumId, messages: albumMessages });
|
||||
index = cursor;
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({ kind: 'message', message });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function collectChatImageMessages(messages: ChatMessage[]) {
|
||||
return messages.filter((message) => message.type === 'IMAGE' && message.storageKey);
|
||||
}
|
||||
121
apps/frontend/lib/chat-media-upload.ts
Normal file
121
apps/frontend/lib/chat-media-upload.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
apiFetch,
|
||||
PresignedUploadResponse,
|
||||
sendChatMessage,
|
||||
uploadMediaObject,
|
||||
type ChatMessage
|
||||
} from '@/lib/api';
|
||||
import { compressChatImage, isCompressibleImageFile, readImageDimensions } from '@/lib/chat-image-compress';
|
||||
import { detectChatMessageType, resolveChatContentType } from '@/lib/chat-media';
|
||||
import { e2eKindFromMessageType, encryptE2EBlob, type E2EMessagePayload } from '@/lib/e2e-chat';
|
||||
|
||||
export interface ChatMediaUploadItem {
|
||||
file: File;
|
||||
sendAsFile: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
type EncryptOutgoing = (payload: E2EMessagePayload) => Promise<string>;
|
||||
|
||||
export async function uploadChatMediaBatch(params: {
|
||||
roomId: string;
|
||||
token: string;
|
||||
items: ChatMediaUploadItem[];
|
||||
caption?: string;
|
||||
isE2E: boolean;
|
||||
userId?: string;
|
||||
peerE2eKey?: string | null;
|
||||
encryptOutgoing?: EncryptOutgoing;
|
||||
}): Promise<ChatMessage[]> {
|
||||
const { roomId, token, items, caption, isE2E, userId, peerE2eKey, encryptOutgoing } = params;
|
||||
if (!items.length) return [];
|
||||
|
||||
const albumId = crypto.randomUUID();
|
||||
const created: ChatMessage[] = [];
|
||||
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index]!;
|
||||
let file = item.file;
|
||||
let width = item.width;
|
||||
let height = item.height;
|
||||
|
||||
const shouldCompress = isCompressibleImageFile(file) && !item.sendAsFile;
|
||||
if (shouldCompress) {
|
||||
const compressed = await compressChatImage(file);
|
||||
file = compressed.file;
|
||||
width = compressed.width ?? width;
|
||||
height = compressed.height ?? height;
|
||||
} else if (isCompressibleImageFile(file) && (!width || !height)) {
|
||||
const dimensions = await readImageDimensions(file);
|
||||
width = dimensions.width;
|
||||
height = dimensions.height;
|
||||
}
|
||||
|
||||
const contentType = resolveChatContentType(file);
|
||||
const messageType = item.sendAsFile && isCompressibleImageFile(item.file) ? 'FILE' : detectChatMessageType(file);
|
||||
const metadata = {
|
||||
fileName: item.file.name,
|
||||
fileSize: file.size,
|
||||
albumId,
|
||||
albumIndex: index,
|
||||
albumCount: items.length,
|
||||
...(width ? { width } : {}),
|
||||
...(height ? { height } : {}),
|
||||
...(item.sendAsFile ? { sendAsFile: true } : {})
|
||||
};
|
||||
|
||||
let uploadFile = file;
|
||||
let uploadContentType = contentType;
|
||||
let messageContent: string | undefined =
|
||||
index === 0 && caption?.trim()
|
||||
? caption.trim()
|
||||
: messageType === 'FILE'
|
||||
? item.file.name
|
||||
: undefined;
|
||||
let isEncrypted = false;
|
||||
|
||||
if (isE2E) {
|
||||
if (!userId || !peerE2eKey || !encryptOutgoing) {
|
||||
throw new Error('Секретный чат недоступен без ключей E2E у обоих участников');
|
||||
}
|
||||
const encryptedBuffer = await encryptE2EBlob(userId, peerE2eKey, await file.arrayBuffer());
|
||||
uploadFile = new File([encryptedBuffer], `${item.file.name}.lendryenc`, { type: 'application/octet-stream' });
|
||||
uploadContentType = 'application/octet-stream';
|
||||
messageContent = await encryptOutgoing({
|
||||
kind: e2eKindFromMessageType(messageType),
|
||||
fileName: item.file.name,
|
||||
mimeType: contentType,
|
||||
fileSize: file.size,
|
||||
text: index === 0 ? caption?.trim() : undefined
|
||||
});
|
||||
isEncrypted = true;
|
||||
}
|
||||
|
||||
const presigned = await apiFetch<PresignedUploadResponse>(
|
||||
`/media/chat/${roomId}/media/upload-url`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contentType: uploadContentType, fileName: uploadFile.name })
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
await uploadMediaObject(presigned, uploadFile, uploadContentType);
|
||||
const message = await sendChatMessage(
|
||||
roomId,
|
||||
{
|
||||
type: messageType,
|
||||
storageKey: presigned.storageKey,
|
||||
mimeType: uploadContentType,
|
||||
content: messageContent,
|
||||
isEncrypted,
|
||||
metadataJson: JSON.stringify(isE2E ? { e2e: true, ...metadata } : metadata)
|
||||
},
|
||||
token
|
||||
);
|
||||
created.push(message);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
Reference in New Issue
Block a user