This commit is contained in:
lendry
2026-06-26 17:49:22 +03:00
parent c23f35e732
commit 4e853f8041
17 changed files with 1547 additions and 167 deletions

View 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>
);
}

View 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>
);
}

View 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 ?? []);
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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}