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;
|
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') {
|
if (event.type === 'bot_message') {
|
||||||
const messageId = Number(payload.messageId);
|
const messageId = Number(payload.messageId);
|
||||||
if (!Number.isFinite(messageId)) {
|
if (!Number.isFinite(messageId)) {
|
||||||
@@ -144,8 +154,9 @@ export function FamilyBotChat({
|
|||||||
|
|
||||||
const replyMarkupJson = markupJsonFromPayload(payload);
|
const replyMarkupJson = markupJsonFromPayload(payload);
|
||||||
const outbound: BotChatMessage = {
|
const outbound: BotChatMessage = {
|
||||||
id: `out-ws-${messageId}`,
|
id: `${eventScope === 'broadcast' ? 'broadcast' : 'out'}-ws-${messageId}`,
|
||||||
direction: 'out',
|
direction: 'out',
|
||||||
|
scope: eventScope,
|
||||||
text: typeof payload.text === 'string' ? payload.text : '',
|
text: typeof payload.text === 'string' ? payload.text : '',
|
||||||
messageType: typeof payload.messageType === 'string' ? payload.messageType : 'text',
|
messageType: typeof payload.messageType === 'string' ? payload.messageType : 'text',
|
||||||
messageId,
|
messageId,
|
||||||
@@ -156,7 +167,9 @@ export function FamilyBotChat({
|
|||||||
};
|
};
|
||||||
|
|
||||||
setMessages((current) => {
|
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 [...withoutDuplicate, outbound].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -191,7 +204,7 @@ export function FamilyBotChat({
|
|||||||
const replyMarkupJson = markupJsonFromPayload(payload);
|
const replyMarkupJson = markupJsonFromPayload(payload);
|
||||||
setMessages((current) =>
|
setMessages((current) =>
|
||||||
current.map((item) =>
|
current.map((item) =>
|
||||||
item.messageId === messageId && item.direction === 'out'
|
item.messageId === messageId && item.direction === 'out' && (item.scope ?? 'private') === eventScope
|
||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
text: typeof payload.text === 'string' ? payload.text : item.text,
|
text: typeof payload.text === 'string' ? payload.text : item.text,
|
||||||
@@ -212,7 +225,7 @@ export function FamilyBotChat({
|
|||||||
|
|
||||||
setMessages((current) =>
|
setMessages((current) =>
|
||||||
current.map((item) =>
|
current.map((item) =>
|
||||||
item.messageId === messageId && item.direction === 'out'
|
item.messageId === messageId && item.direction === 'out' && (item.scope ?? 'private') === eventScope
|
||||||
? {
|
? {
|
||||||
...item,
|
...item,
|
||||||
isPinned: Boolean(payload.isPinned),
|
isPinned: Boolean(payload.isPinned),
|
||||||
@@ -223,7 +236,7 @@ export function FamilyBotChat({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [botRef, loadMessages, subscribe]);
|
}, [botRef, loadMessages, roomId, subscribe]);
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
if (!token || !draft.trim() || sending) return;
|
if (!token || !draft.trim() || sending) return;
|
||||||
@@ -271,12 +284,18 @@ export function FamilyBotChat({
|
|||||||
) : messages.length ? (
|
) : messages.length ? (
|
||||||
messages.map((message) => {
|
messages.map((message) => {
|
||||||
const mine = message.direction === 'in';
|
const mine = message.direction === 'in';
|
||||||
|
const isBroadcast = message.scope === 'broadcast';
|
||||||
const hasKeyboard = Boolean(message.replyMarkupJson?.trim());
|
const hasKeyboard = Boolean(message.replyMarkupJson?.trim());
|
||||||
return (
|
return (
|
||||||
<div key={message.id} id={`bot-msg-${message.id}`} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
|
<div key={message.id} id={`bot-msg-${message.id}`} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
|
||||||
<BotChatMessageContextMenu text={message.text} onCopy={() => showToast('Текст скопирован')}>
|
<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')}>
|
<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>
|
<p className="whitespace-pre-wrap text-sm">{message.text || '…'}</p>
|
||||||
{!mine && hasKeyboard && message.messageId > 0 ? (
|
{!mine && hasKeyboard && message.messageId > 0 ? (
|
||||||
<BotMessageKeyboard
|
<BotMessageKeyboard
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ import { BotMiniAppSheet, FamilyBotChat } from '@/components/family/family-bot-c
|
|||||||
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
|
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
|
||||||
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
|
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
|
||||||
import { ChatForwardDialog } from '@/components/chat/chat-forward-dialog';
|
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 { ChatDateSeparator } from '@/components/chat/chat-date-separator';
|
||||||
import { ChatToolsDialog, type ChatToolsTab } from '@/components/chat/chat-tools-dialog';
|
import { ChatToolsDialog, type ChatToolsTab } from '@/components/chat/chat-tools-dialog';
|
||||||
import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
|
import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
|
||||||
@@ -126,6 +135,8 @@ import {
|
|||||||
parseMessageMetadata,
|
parseMessageMetadata,
|
||||||
resolveChatContentType
|
resolveChatContentType
|
||||||
} from '@/lib/chat-media';
|
} from '@/lib/chat-media';
|
||||||
|
import { collectChatImageMessages, parseChatMediaMetadata } from '@/lib/chat-media-album';
|
||||||
|
import { uploadChatMediaBatch } from '@/lib/chat-media-upload';
|
||||||
|
|
||||||
interface PresignedUploadResponse {
|
interface PresignedUploadResponse {
|
||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
@@ -211,6 +222,31 @@ function familyMemberRoleLabel(role: string) {
|
|||||||
return role === 'owner' ? 'Создатель семьи' : 'Участник';
|
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 }) {
|
export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, token } = useAuth();
|
const { user, token } = useAuth();
|
||||||
@@ -258,6 +294,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
const [replyToMessage, setReplyToMessage] = useState<ChatMessage | null>(null);
|
const [replyToMessage, setReplyToMessage] = useState<ChatMessage | null>(null);
|
||||||
const [chatToolsOpen, setChatToolsOpen] = useState(false);
|
const [chatToolsOpen, setChatToolsOpen] = useState(false);
|
||||||
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
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 messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
|
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 recordingStartedAtRef = useRef<number | null>(null);
|
||||||
const mediaUrlsRef = useRef<Record<string, LoadedChatMedia>>({});
|
const mediaUrlsRef = useRef<Record<string, LoadedChatMedia>>({});
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dragDepthRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mediaUrlsRef.current = mediaUrls;
|
mediaUrlsRef.current = mediaUrls;
|
||||||
@@ -279,6 +320,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const visibleMessages = useMemo(() => messages.filter((message) => !message.isDeleted), [messages]);
|
const visibleMessages = useMemo(() => messages.filter((message) => !message.isDeleted), [messages]);
|
||||||
|
const chatImageMessages = useMemo(() => collectChatImageMessages(visibleMessages), [visibleMessages]);
|
||||||
const messageById = useMemo(
|
const messageById = useMemo(
|
||||||
() => new Map(visibleMessages.map((message) => [message.id, message])),
|
() => new Map(visibleMessages.map((message) => [message.id, message])),
|
||||||
[visibleMessages]
|
[visibleMessages]
|
||||||
@@ -1127,6 +1169,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
|
|
||||||
async function uploadChatFile(file: File, options?: { voice?: boolean; durationMs?: number }) {
|
async function uploadChatFile(file: File, options?: { voice?: boolean; durationMs?: number }) {
|
||||||
if (!token || !activeRoomId || !activeRoom) return;
|
if (!token || !activeRoomId || !activeRoom) return;
|
||||||
|
if (!options?.voice) {
|
||||||
|
queueMediaFiles([file]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
try {
|
||||||
const contentType = resolveChatContentType(file);
|
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() {
|
async function submitPoll() {
|
||||||
if (!token || !activeRoomId) return;
|
if (!token || !activeRoomId) return;
|
||||||
const options = pollOptions.map((item) => item.trim()).filter(Boolean);
|
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 ? (
|
{pinnedMessage ? (
|
||||||
<ChatPinnedMessageBanner
|
<ChatPinnedMessageBanner
|
||||||
senderName={pinnedMessage.senderName}
|
senderName={pinnedMessage.senderName}
|
||||||
@@ -1540,6 +1687,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
) : null}
|
) : null}
|
||||||
<div className={cn('flex-1 space-y-2 overflow-y-auto px-4 py-4', isDragging && 'select-none')}>
|
<div className={cn('flex-1 space-y-2 overflow-y-auto px-4 py-4', isDragging && 'select-none')}>
|
||||||
{visibleMessages.map((message, messageIndex) => {
|
{visibleMessages.map((message, messageIndex) => {
|
||||||
|
if (shouldSkipGroupedAlbumMessage(visibleMessages, messageIndex)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const previousMessage = visibleMessages[messageIndex - 1];
|
const previousMessage = visibleMessages[messageIndex - 1];
|
||||||
const showDateSeparator = shouldShowChatDateSeparator(message.createdAt, previousMessage?.createdAt);
|
const showDateSeparator = shouldShowChatDateSeparator(message.createdAt, previousMessage?.createdAt);
|
||||||
|
|
||||||
@@ -1591,6 +1742,8 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
const largeEmojiNative = isLargeEmoji
|
const largeEmojiNative = isLargeEmoji
|
||||||
? getSingleEmojiNative({ type: message.type, content: emojiContent ?? message.content, visibleText })
|
? 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 = (
|
const bubble = (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -1642,6 +1795,22 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
) : message.storageKey ? (
|
) : 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 media = mediaUrls[message.storageKey];
|
||||||
const meta = parseMessageMetadata(message.metadataJson);
|
const meta = parseMessageMetadata(message.metadataJson);
|
||||||
const fileName = e2ePayload?.fileName || meta.fileName || message.content || 'Файл';
|
const fileName = e2ePayload?.fileName || meta.fileName || message.content || 'Файл';
|
||||||
@@ -1655,8 +1824,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
}
|
}
|
||||||
if (message.type === 'IMAGE') {
|
if (message.type === 'IMAGE') {
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<button type="button" className="block overflow-hidden rounded-xl" onClick={() => openImageLightbox(message)}>
|
||||||
<img src={media.blobUrl} alt={fileName} className="max-h-72 max-w-full rounded-xl object-cover" />
|
{/* 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') {
|
if (message.type === 'VOICE' || message.type === 'AUDIO') {
|
||||||
@@ -1933,7 +2104,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -1941,11 +2112,44 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<input ref={fileInputRef} type="file" className="hidden" onChange={(event) => {
|
<input
|
||||||
const file = event.target.files?.[0];
|
ref={fileInputRef}
|
||||||
if (file) void uploadChatFile(file);
|
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 = '';
|
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
|
<FamilyInviteDialog
|
||||||
groupId={groupId}
|
groupId={groupId}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
|
|||||||
import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat';
|
import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat';
|
||||||
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
|
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
|
||||||
import { ChatDateSeparator } from '@/components/chat/chat-date-separator';
|
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 { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
|
||||||
import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
|
import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
|
||||||
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
|
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
|
||||||
@@ -58,6 +65,7 @@ import {
|
|||||||
isSystemMessage
|
isSystemMessage
|
||||||
} from '@/lib/chat-message-utils';
|
} from '@/lib/chat-message-utils';
|
||||||
import { detectChatMessageType, parseMessageMetadata, resolveChatContentType } from '@/lib/chat-media';
|
import { detectChatMessageType, parseMessageMetadata, resolveChatContentType } from '@/lib/chat-media';
|
||||||
|
import { uploadChatMediaBatch } from '@/lib/chat-media-upload';
|
||||||
import { getSingleEmojiNative, isSingleEmojiMessage } from '@/lib/chat-emoji-display';
|
import { getSingleEmojiNative, isSingleEmojiMessage } from '@/lib/chat-emoji-display';
|
||||||
import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils';
|
import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils';
|
||||||
import { encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat';
|
import { encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat';
|
||||||
@@ -122,9 +130,12 @@ export function MiniFamilyChat() {
|
|||||||
const [pollOptions, setPollOptions] = useState(['', '']);
|
const [pollOptions, setPollOptions] = useState(['', '']);
|
||||||
const [chatToolsOpen, setChatToolsOpen] = useState(false);
|
const [chatToolsOpen, setChatToolsOpen] = useState(false);
|
||||||
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
||||||
|
const [mediaDropVisible, setMediaDropVisible] = useState(false);
|
||||||
|
const mediaComposer = useChatMediaComposer();
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
|
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dragDepthRef = useRef(0);
|
||||||
const previousGroupIdRef = useRef<string | null>(null);
|
const previousGroupIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const visibleMessages = useMemo(
|
const visibleMessages = useMemo(
|
||||||
@@ -580,65 +591,66 @@ export function MiniFamilyChat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function uploadChatFile(file: File) {
|
async function uploadChatFile(file: File) {
|
||||||
if (!token || !activeRoom || sending) return;
|
queueMediaFiles([file]);
|
||||||
setSending(true);
|
}
|
||||||
try {
|
|
||||||
const contentType = resolveChatContentType(file);
|
|
||||||
const messageType = detectChatMessageType(file);
|
|
||||||
const metadata = { fileName: file.name, fileSize: file.size };
|
|
||||||
|
|
||||||
let uploadFile = file;
|
const queueMediaFiles = useCallback(
|
||||||
let uploadContentType = contentType;
|
(files: File[], options?: { sendAsFile?: boolean }) => {
|
||||||
let messageContent: string | undefined = messageType === 'FILE' ? file.name : undefined;
|
if (!activeRoom || view !== 'chat' || !files.length) return;
|
||||||
let isEncrypted = false;
|
if (files.length > mediaComposer.maxFiles) {
|
||||||
|
showToast(`Можно отправить не более ${mediaComposer.maxFiles} файлов за раз`);
|
||||||
if (activeRoomIsE2E) {
|
}
|
||||||
if (!user?.id || !peerE2eKey) {
|
const limited = files.slice(0, mediaComposer.maxFiles);
|
||||||
showToast('Секретный чат недоступен без ключей E2E у обоих участников');
|
if (mediaComposer.open && mediaComposer.items.length) {
|
||||||
|
void mediaComposer.addFiles(limited);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const encryptedBuffer = await encryptE2EBlob(user.id, peerE2eKey, await file.arrayBuffer());
|
void mediaComposer.openWithFiles(limited, options);
|
||||||
uploadFile = new File([encryptedBuffer], `${file.name}.lendryenc`, { type: 'application/octet-stream' });
|
},
|
||||||
uploadContentType = 'application/octet-stream';
|
[activeRoom, mediaComposer, view]
|
||||||
messageContent = await encryptOutgoing({
|
);
|
||||||
kind: e2eKindFromMessageType(messageType),
|
|
||||||
fileName: file.name,
|
|
||||||
mimeType: contentType,
|
|
||||||
fileSize: file.size
|
|
||||||
});
|
|
||||||
isEncrypted = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const presigned = await apiFetch<PresignedUploadResponse>(
|
async function submitMediaCompose() {
|
||||||
`/media/chat/${activeRoom.id}/media/upload-url`,
|
if (!token || !activeRoom || !mediaComposer.items.length) return;
|
||||||
{
|
setSending(true);
|
||||||
method: 'POST',
|
try {
|
||||||
body: JSON.stringify({ contentType: uploadContentType, fileName: uploadFile.name })
|
const uploaded = await uploadChatMediaBatch({
|
||||||
},
|
roomId: activeRoom.id,
|
||||||
token
|
token,
|
||||||
);
|
items: mediaComposer.items.map((item) => ({
|
||||||
await uploadMediaObject(presigned, uploadFile, uploadContentType);
|
file: item.file,
|
||||||
const message = await sendChatMessage(
|
sendAsFile: item.sendAsFile,
|
||||||
activeRoom.id,
|
width: item.width,
|
||||||
{
|
height: item.height
|
||||||
type: messageType,
|
})),
|
||||||
storageKey: presigned.storageKey,
|
caption: mediaComposer.caption,
|
||||||
mimeType: uploadContentType,
|
isE2E: activeRoomIsE2E,
|
||||||
content: messageContent,
|
userId: user?.id,
|
||||||
isEncrypted,
|
peerE2eKey,
|
||||||
metadataJson: JSON.stringify(activeRoomIsE2E ? { e2e: true, ...metadata } : metadata)
|
encryptOutgoing
|
||||||
},
|
});
|
||||||
token
|
setMessages((current) => [...current, ...uploaded]);
|
||||||
);
|
mediaComposer.close();
|
||||||
setMessages((current) => [...current, message]);
|
|
||||||
void loadRooms();
|
void loadRooms();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(getApiErrorMessage(error, 'Не удалось отправить файл') ?? 'Ошибка');
|
showToast(getApiErrorMessage(error, 'Не удалось отправить файлы') ?? 'Ошибка');
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
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() {
|
async function submitPoll() {
|
||||||
if (!token || !activeRoom) return;
|
if (!token || !activeRoom) return;
|
||||||
const options = pollOptions.map((item) => item.trim()).filter(Boolean);
|
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 ? (
|
{pinnedMessage ? (
|
||||||
<ChatPinnedMessageBanner
|
<ChatPinnedMessageBanner
|
||||||
senderName={pinnedMessage.senderName}
|
senderName={pinnedMessage.senderName}
|
||||||
@@ -1268,7 +1313,7 @@ export function MiniFamilyChat() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1335,14 +1380,30 @@ export function MiniFamilyChat() {
|
|||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
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"
|
className="hidden"
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const file = event.target.files?.[0];
|
const files = Array.from(event.target.files ?? []);
|
||||||
if (file) void uploadChatFile(file);
|
if (files.length) queueMediaFiles(files);
|
||||||
event.target.value = '';
|
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
|
<BotMiniAppSheet
|
||||||
url={miniAppUrl}
|
url={miniAppUrl}
|
||||||
title={miniAppTitle}
|
title={miniAppTitle}
|
||||||
|
|||||||
@@ -747,6 +747,7 @@ export interface FamilyGroup {
|
|||||||
export interface BotChatMessage {
|
export interface BotChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
direction: 'in' | 'out';
|
direction: 'in' | 'out';
|
||||||
|
scope?: 'private' | 'broadcast';
|
||||||
text: string;
|
text: string;
|
||||||
messageType: string;
|
messageType: string;
|
||||||
messageId: number;
|
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;
|
||||||
|
}
|
||||||
@@ -408,6 +408,7 @@ model ChatRoom {
|
|||||||
group FamilyGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
group FamilyGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||||
members ChatRoomMember[]
|
members ChatRoomMember[]
|
||||||
messages ChatMessage[]
|
messages ChatMessage[]
|
||||||
|
botChats BotChat[]
|
||||||
|
|
||||||
@@index([groupId, type])
|
@@index([groupId, type])
|
||||||
@@index([groupId, type, peerUserId])
|
@@index([groupId, type, peerUserId])
|
||||||
@@ -571,19 +572,23 @@ model BotChat {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
botId String
|
botId String
|
||||||
recipientUserId String?
|
recipientUserId String?
|
||||||
|
roomId String?
|
||||||
telegramChatId BigInt
|
telegramChatId BigInt
|
||||||
chatType String @default("private")
|
chatType String @default("private")
|
||||||
nextInboundMessageId Int @default(1)
|
nextInboundMessageId Int @default(1)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
|
bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade)
|
||||||
|
room ChatRoom? @relation(fields: [roomId], references: [id], onDelete: Cascade)
|
||||||
messages BotMessage[]
|
messages BotMessage[]
|
||||||
inboundMessages BotInboundMessage[]
|
inboundMessages BotInboundMessage[]
|
||||||
menuButton ChatMenuButton?
|
menuButton ChatMenuButton?
|
||||||
|
|
||||||
@@unique([botId, recipientUserId])
|
@@unique([botId, recipientUserId])
|
||||||
|
@@unique([botId, roomId])
|
||||||
@@unique([botId, telegramChatId])
|
@@unique([botId, telegramChatId])
|
||||||
@@index([botId])
|
@@index([botId])
|
||||||
|
@@index([roomId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ChatMenuButton {
|
model ChatMenuButton {
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export class BotApiService {
|
|||||||
payload: this.serializer.buildStoredPayload(parsedMarkup)
|
payload: this.serializer.buildStoredPayload(parsedMarkup)
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.publishBotMessage(context.recipient.id, bot as ValidatedBot, context.chat, message, parsedMarkup.internal);
|
await this.publishBotMessage(context.recipient?.id ?? null, bot as ValidatedBot, context.chat, message, parsedMarkup.internal);
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,14 +247,16 @@ export class BotApiService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const chat = roomId
|
const privateChat = roomId
|
||||||
? await this.resolveSharedFamilyBotChat(bot.id, userId, roomId)
|
? await this.resolvePrivateBotChatForRoom(bot.id, userId, roomId)
|
||||||
: await this.prisma.botChat.findUnique({
|
: await this.prisma.botChat.findUnique({
|
||||||
where: { botId_recipientUserId: { botId: bot.id, recipientUserId: userId } },
|
where: { botId_recipientUserId: { botId: bot.id, recipientUserId: userId } },
|
||||||
include: { menuButton: true }
|
include: { menuButton: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
const composer = this.resolveComposerForBot(bot, chat?.menuButton?.menuButton ?? null);
|
const groupChat = roomId ? await this.ensureRoomBotChat(bot.id, roomId) : null;
|
||||||
|
|
||||||
|
const composer = this.resolveComposerForBot(bot, privateChat?.menuButton?.menuButton ?? null);
|
||||||
const emptyResponse = {
|
const emptyResponse = {
|
||||||
messages: [] as Array<Record<string, unknown>>,
|
messages: [] as Array<Record<string, unknown>>,
|
||||||
botUsername: bot.username,
|
botUsername: bot.username,
|
||||||
@@ -266,25 +268,31 @@ export class BotApiService {
|
|||||||
manageWebAppUrl: buildBotManageWebAppUrl(bot.id)
|
manageWebAppUrl: buildBotManageWebAppUrl(bot.id)
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!chat) {
|
if (!privateChat && !groupChat) {
|
||||||
return emptyResponse;
|
return emptyResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chatIds = [privateChat?.id, groupChat?.id].filter(Boolean) as string[];
|
||||||
|
|
||||||
const [outbound, inbound] = await Promise.all([
|
const [outbound, inbound] = await Promise.all([
|
||||||
this.prisma.botMessage.findMany({
|
this.prisma.botMessage.findMany({
|
||||||
where: { botChatId: chat.id },
|
where: { botChatId: { in: chatIds } },
|
||||||
orderBy: { createdAt: 'asc' }
|
orderBy: { createdAt: 'asc' },
|
||||||
|
include: { botChat: { select: { chatType: true, roomId: true } } }
|
||||||
}),
|
}),
|
||||||
this.prisma.botInboundMessage.findMany({
|
privateChat
|
||||||
where: { botChatId: chat.id },
|
? this.prisma.botInboundMessage.findMany({
|
||||||
|
where: { botChatId: privateChat.id, senderUserId: userId },
|
||||||
orderBy: { createdAt: 'asc' }
|
orderBy: { createdAt: 'asc' }
|
||||||
})
|
})
|
||||||
|
: Promise.resolve([])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const messages = [
|
const messages = [
|
||||||
...inbound.map((item) => ({
|
...inbound.map((item) => ({
|
||||||
id: `in-${item.id}`,
|
id: `in-${item.id}`,
|
||||||
direction: 'in' as const,
|
direction: 'in' as const,
|
||||||
|
scope: 'private' as const,
|
||||||
text: item.text ?? '',
|
text: item.text ?? '',
|
||||||
messageType: 'text',
|
messageType: 'text',
|
||||||
messageId: item.telegramMsgId,
|
messageId: item.telegramMsgId,
|
||||||
@@ -296,9 +304,11 @@ export class BotApiService {
|
|||||||
...outbound.map((item) => {
|
...outbound.map((item) => {
|
||||||
const payload = this.serializer.readPayload(item.payload);
|
const payload = this.serializer.readPayload(item.payload);
|
||||||
const replyMarkup = payload.telegramReplyMarkup ?? payload.replyMarkup;
|
const replyMarkup = payload.telegramReplyMarkup ?? payload.replyMarkup;
|
||||||
|
const isBroadcast = item.botChat.chatType === 'group';
|
||||||
return {
|
return {
|
||||||
id: `out-${item.id}`,
|
id: `${isBroadcast ? 'broadcast' : 'out'}-${item.id}`,
|
||||||
direction: 'out' as const,
|
direction: 'out' as const,
|
||||||
|
scope: isBroadcast ? ('broadcast' as const) : ('private' as const),
|
||||||
text: item.text ?? '',
|
text: item.text ?? '',
|
||||||
messageType: item.messageType ?? 'text',
|
messageType: item.messageType ?? 'text',
|
||||||
messageId: item.telegramMsgId,
|
messageId: item.telegramMsgId,
|
||||||
@@ -338,7 +348,7 @@ export class BotApiService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveSharedFamilyBotChat(botId: string, viewerUserId: string, roomId: string) {
|
private async resolvePrivateBotChatForRoom(botId: string, viewerUserId: string, roomId: string) {
|
||||||
const room = await this.prisma.chatRoom.findUnique({
|
const room = await this.prisma.chatRoom.findUnique({
|
||||||
where: { id: roomId },
|
where: { id: roomId },
|
||||||
include: {
|
include: {
|
||||||
@@ -352,14 +362,62 @@ export class BotApiService {
|
|||||||
if (!botMember || room.peerUserId !== botMember.userId) {
|
if (!botMember || room.peerUserId !== botMember.userId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const ownerUserId =
|
|
||||||
room.createdById ??
|
const existing = await this.prisma.botChat.findUnique({
|
||||||
room.members.find((member) => !member.user.linkedBotId)?.userId ??
|
where: { botId_recipientUserId: { botId, recipientUserId: viewerUserId } },
|
||||||
viewerUserId;
|
|
||||||
return this.prisma.botChat.findUnique({
|
|
||||||
where: { botId_recipientUserId: { botId, recipientUserId: ownerUserId } },
|
|
||||||
include: { menuButton: true }
|
include: { menuButton: true }
|
||||||
});
|
});
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const telegramChatId = deriveTelegramChatId(`${botId}:${viewerUserId}`);
|
||||||
|
try {
|
||||||
|
return await this.prisma.botChat.create({
|
||||||
|
data: {
|
||||||
|
botId,
|
||||||
|
recipientUserId: viewerUserId,
|
||||||
|
telegramChatId,
|
||||||
|
chatType: 'private'
|
||||||
|
},
|
||||||
|
include: { menuButton: true }
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return this.prisma.botChat.findUnique({
|
||||||
|
where: { botId_recipientUserId: { botId, recipientUserId: viewerUserId } },
|
||||||
|
include: { menuButton: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureRoomBotChat(botId: string, roomId: string) {
|
||||||
|
const existing = await this.prisma.botChat.findUnique({
|
||||||
|
where: { botId_roomId: { botId, roomId } }
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const telegramChatId = deriveTelegramChatId(`${botId}:room:${roomId}`);
|
||||||
|
try {
|
||||||
|
return await this.prisma.botChat.create({
|
||||||
|
data: {
|
||||||
|
botId,
|
||||||
|
roomId,
|
||||||
|
telegramChatId,
|
||||||
|
chatType: 'group',
|
||||||
|
recipientUserId: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
const fallback = await this.prisma.botChat.findUnique({
|
||||||
|
where: { botId_roomId: { botId, roomId } }
|
||||||
|
});
|
||||||
|
if (!fallback) {
|
||||||
|
throw new Error('Не удалось создать групповой чат бота');
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async setChatMenuButton(bot: ValidatedBot, payload: SetChatMenuButtonPayload) {
|
private async setChatMenuButton(bot: ValidatedBot, payload: SetChatMenuButtonPayload) {
|
||||||
@@ -444,7 +502,9 @@ export class BotApiService {
|
|||||||
},
|
},
|
||||||
override
|
override
|
||||||
);
|
);
|
||||||
|
if (context.recipient) {
|
||||||
await this.publishMenuButtonUpdate(context.recipient.id, bot, composer);
|
await this.publishMenuButtonUpdate(context.recipient.id, bot, composer);
|
||||||
|
}
|
||||||
|
|
||||||
return this.wrapOk(true);
|
return this.wrapOk(true);
|
||||||
}
|
}
|
||||||
@@ -485,7 +545,7 @@ export class BotApiService {
|
|||||||
payload: this.serializer.buildStoredPayload(parsedMarkup)
|
payload: this.serializer.buildStoredPayload(parsedMarkup)
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.publishBotMessage(context.recipient.id, bot, context.chat, message, parsedMarkup.internal);
|
await this.publishBotMessage(context.recipient?.id ?? null, bot, context.chat, message, parsedMarkup.internal);
|
||||||
|
|
||||||
return this.wrapOk(this.buildTelegramMessage(bot, context, message));
|
return this.wrapOk(this.buildTelegramMessage(bot, context, message));
|
||||||
}
|
}
|
||||||
@@ -509,7 +569,7 @@ export class BotApiService {
|
|||||||
payload: this.serializer.buildStoredPayload(parsedMarkup)
|
payload: this.serializer.buildStoredPayload(parsedMarkup)
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.publishBotMessage(context.recipient.id, bot, context.chat, message, parsedMarkup.internal, 'photo', photo);
|
await this.publishBotMessage(context.recipient?.id ?? null, bot, context.chat, message, parsedMarkup.internal, 'photo', photo);
|
||||||
|
|
||||||
return this.wrapOk(this.buildTelegramMessage(bot, context, message));
|
return this.wrapOk(this.buildTelegramMessage(bot, context, message));
|
||||||
}
|
}
|
||||||
@@ -533,7 +593,7 @@ export class BotApiService {
|
|||||||
payload: this.serializer.buildStoredPayload(parsedMarkup)
|
payload: this.serializer.buildStoredPayload(parsedMarkup)
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.publishBotMessage(context.recipient.id, bot, context.chat, message, parsedMarkup.internal, 'document', document);
|
await this.publishBotMessage(context.recipient?.id ?? null, bot, context.chat, message, parsedMarkup.internal, 'document', document);
|
||||||
|
|
||||||
return this.wrapOk(this.buildTelegramMessage(bot, context, message));
|
return this.wrapOk(this.buildTelegramMessage(bot, context, message));
|
||||||
}
|
}
|
||||||
@@ -570,7 +630,7 @@ export class BotApiService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.publishBotMessageEdited(located.recipient.id, bot, located.chat, updated, parsedMarkup.internal);
|
await this.publishBotMessageEdited(located.recipient?.id ?? null, bot, located.chat, updated, parsedMarkup.internal);
|
||||||
|
|
||||||
return this.wrapOk(this.buildTelegramMessage(bot, located, updated));
|
return this.wrapOk(this.buildTelegramMessage(bot, located, updated));
|
||||||
}
|
}
|
||||||
@@ -599,7 +659,7 @@ export class BotApiService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.publishBotMessageEdited(located.recipient.id, bot, located.chat, updated, parsedMarkup.internal);
|
await this.publishBotMessageEdited(located.recipient?.id ?? null, bot, located.chat, updated, parsedMarkup.internal);
|
||||||
|
|
||||||
return this.wrapOk(this.buildTelegramMessage(bot, located, updated));
|
return this.wrapOk(this.buildTelegramMessage(bot, located, updated));
|
||||||
}
|
}
|
||||||
@@ -626,8 +686,8 @@ export class BotApiService {
|
|||||||
return this.wrapOk({
|
return this.wrapOk({
|
||||||
id: Number(context.chat.telegramChatId),
|
id: Number(context.chat.telegramChatId),
|
||||||
type: context.chat.chatType,
|
type: context.chat.chatType,
|
||||||
first_name: context.recipient.displayName,
|
first_name: context.recipient?.displayName ?? bot.name,
|
||||||
...(context.recipient.username ? { username: context.recipient.username } : {}),
|
...(context.recipient?.username ? { username: context.recipient.username } : {}),
|
||||||
...(pinned ? { pinned_message: this.buildTelegramMessage(bot, context, pinned) } : {})
|
...(pinned ? { pinned_message: this.buildTelegramMessage(bot, context, pinned) } : {})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -676,7 +736,7 @@ export class BotApiService {
|
|||||||
data: pinned ? { isPinned: true, pinnedAt: new Date() } : { isPinned: false, pinnedAt: null }
|
data: pinned ? { isPinned: true, pinnedAt: new Date() } : { isPinned: false, pinnedAt: null }
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.publishBotMessagePinned(context.recipient.id, bot, context.chat, updated);
|
await this.publishBotMessagePinned(context.recipient?.id ?? null, bot, context.chat, updated);
|
||||||
return this.wrapOk(true);
|
return this.wrapOk(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,8 +898,34 @@ export class BotApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async resolveChatContext(botId: string, chatIdRaw: string) {
|
private async resolveChatContext(botId: string, chatIdRaw: string) {
|
||||||
|
const numericChatId = chatIdRaw.match(/^\d+$/) ? BigInt(chatIdRaw) : null;
|
||||||
|
if (numericChatId) {
|
||||||
|
const chat = await this.prisma.botChat.findUnique({
|
||||||
|
where: { botId_telegramChatId: { botId, telegramChatId: numericChatId } }
|
||||||
|
});
|
||||||
|
if (!chat) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (chat.chatType === 'group') {
|
||||||
|
return { recipient: null, chat };
|
||||||
|
}
|
||||||
|
if (!chat.recipientUserId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const recipient = await this.prisma.user.findUnique({
|
||||||
|
where: { id: chat.recipientUserId },
|
||||||
|
select: { id: true, displayName: true, username: true, status: true, deletedAt: true }
|
||||||
|
});
|
||||||
|
if (!recipient || recipient.deletedAt || recipient.status !== 'ACTIVE') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { recipient, chat };
|
||||||
|
}
|
||||||
|
|
||||||
const recipient = await this.resolveRecipient(chatIdRaw, botId);
|
const recipient = await this.resolveRecipient(chatIdRaw, botId);
|
||||||
if (!recipient) return null;
|
if (!recipient) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const chat = await this.ensureBotChat(botId, recipient.id, chatIdRaw);
|
const chat = await this.ensureBotChat(botId, recipient.id, chatIdRaw);
|
||||||
return { recipient, chat };
|
return { recipient, chat };
|
||||||
@@ -848,7 +934,7 @@ export class BotApiService {
|
|||||||
private buildTelegramMessage(
|
private buildTelegramMessage(
|
||||||
bot: ValidatedBot,
|
bot: ValidatedBot,
|
||||||
context: {
|
context: {
|
||||||
recipient: { id: string; displayName: string; username: string | null };
|
recipient: { id: string; displayName: string; username: string | null } | null;
|
||||||
chat: { telegramChatId: bigint; chatType: string };
|
chat: { telegramChatId: bigint; chatType: string };
|
||||||
},
|
},
|
||||||
message: {
|
message: {
|
||||||
@@ -869,16 +955,16 @@ export class BotApiService {
|
|||||||
chat: {
|
chat: {
|
||||||
telegramChatId: context.chat.telegramChatId,
|
telegramChatId: context.chat.telegramChatId,
|
||||||
chatType: context.chat.chatType,
|
chatType: context.chat.chatType,
|
||||||
recipientDisplayName: context.recipient.displayName,
|
recipientDisplayName: context.recipient?.displayName ?? bot.name,
|
||||||
recipientUsername: context.recipient.username
|
recipientUsername: context.recipient?.username ?? null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async publishBotMessage(
|
private async publishBotMessage(
|
||||||
userId: string,
|
userId: string | null,
|
||||||
bot: ValidatedBot,
|
bot: ValidatedBot,
|
||||||
chat: { telegramChatId: bigint; chatType: string },
|
chat: { telegramChatId: bigint; chatType: string; roomId?: string | null },
|
||||||
message: {
|
message: {
|
||||||
telegramMsgId: number;
|
telegramMsgId: number;
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
@@ -900,16 +986,25 @@ export class BotApiService {
|
|||||||
messageType: message.messageType ?? mediaType ?? 'text',
|
messageType: message.messageType ?? mediaType ?? 'text',
|
||||||
mediaUrl: message.mediaUrl ?? mediaUrl ?? null,
|
mediaUrl: message.mediaUrl ?? mediaUrl ?? null,
|
||||||
replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null,
|
replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null,
|
||||||
telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null
|
telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null,
|
||||||
|
scope: chat.chatType === 'group' ? 'broadcast' : 'private',
|
||||||
|
roomId: chat.roomId ?? null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (chat.chatType === 'group' && chat.roomId) {
|
||||||
|
await this.publishToRoomMembers(chat.roomId, bot, 'bot_message', message.text ?? '', payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
await this.notifications.publishRealtime(userId, 'bot_message', bot.name, message.text ?? '', payload);
|
await this.notifications.publishRealtime(userId, 'bot_message', bot.name, message.text ?? '', payload);
|
||||||
await this.publishToSharedBotRoomMembers(userId, bot, 'bot_message', message.text ?? '', payload);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async publishBotMessagePinned(
|
private async publishBotMessagePinned(
|
||||||
userId: string,
|
userId: string | null,
|
||||||
bot: ValidatedBot,
|
bot: ValidatedBot,
|
||||||
chat: { telegramChatId: bigint; chatType: string },
|
chat: { telegramChatId: bigint; chatType: string; roomId?: string | null },
|
||||||
message: {
|
message: {
|
||||||
telegramMsgId: number;
|
telegramMsgId: number;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
@@ -922,16 +1017,25 @@ export class BotApiService {
|
|||||||
chatId: chat.telegramChatId.toString(),
|
chatId: chat.telegramChatId.toString(),
|
||||||
messageId: message.telegramMsgId,
|
messageId: message.telegramMsgId,
|
||||||
isPinned: Boolean(message.isPinned),
|
isPinned: Boolean(message.isPinned),
|
||||||
pinnedAt: message.pinnedAt?.toISOString() ?? null
|
pinnedAt: message.pinnedAt?.toISOString() ?? null,
|
||||||
|
scope: chat.chatType === 'group' ? 'broadcast' : 'private',
|
||||||
|
roomId: chat.roomId ?? null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (chat.chatType === 'group' && chat.roomId) {
|
||||||
|
await this.publishToRoomMembers(chat.roomId, bot, 'bot_message_pinned', '', payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
await this.notifications.publishRealtime(userId, 'bot_message_pinned', bot.name, '', payload);
|
await this.notifications.publishRealtime(userId, 'bot_message_pinned', bot.name, '', payload);
|
||||||
await this.publishToSharedBotRoomMembers(userId, bot, 'bot_message_pinned', '', payload);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async publishBotMessageEdited(
|
private async publishBotMessageEdited(
|
||||||
userId: string,
|
userId: string | null,
|
||||||
bot: ValidatedBot,
|
bot: ValidatedBot,
|
||||||
chat: { telegramChatId: bigint; chatType: string },
|
chat: { telegramChatId: bigint; chatType: string; roomId?: string | null },
|
||||||
message: {
|
message: {
|
||||||
telegramMsgId: number;
|
telegramMsgId: number;
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
@@ -953,41 +1057,46 @@ export class BotApiService {
|
|||||||
mediaUrl: message.mediaUrl ?? null,
|
mediaUrl: message.mediaUrl ?? null,
|
||||||
replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null,
|
replyMarkup: replyMarkup ?? payloadMarkup.replyMarkup ?? null,
|
||||||
telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null,
|
telegramReplyMarkup: payloadMarkup.telegramReplyMarkup ?? null,
|
||||||
editedAt: message.editedAt?.toISOString() ?? new Date().toISOString()
|
editedAt: message.editedAt?.toISOString() ?? new Date().toISOString(),
|
||||||
|
scope: chat.chatType === 'group' ? 'broadcast' : 'private',
|
||||||
|
roomId: chat.roomId ?? null
|
||||||
};
|
};
|
||||||
await this.notifications.publishRealtime(userId, 'bot_message_edited', bot.name, message.text ?? '', payload);
|
|
||||||
await this.publishToSharedBotRoomMembers(userId, bot, 'bot_message_edited', message.text ?? '', payload);
|
if (chat.chatType === 'group' && chat.roomId) {
|
||||||
|
await this.publishToRoomMembers(chat.roomId, bot, 'bot_message_edited', message.text ?? '', payload);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async publishToSharedBotRoomMembers(
|
if (userId) {
|
||||||
ownerUserId: string,
|
await this.notifications.publishRealtime(userId, 'bot_message_edited', bot.name, message.text ?? '', payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async publishToRoomMembers(
|
||||||
|
roomId: string,
|
||||||
bot: ValidatedBot,
|
bot: ValidatedBot,
|
||||||
eventType: string,
|
eventType: string,
|
||||||
message: string,
|
message: string,
|
||||||
payload: Record<string, unknown>
|
payload: Record<string, unknown>
|
||||||
) {
|
) {
|
||||||
const rooms = await this.prisma.chatRoom.findMany({
|
const room = await this.prisma.chatRoom.findUnique({
|
||||||
where: {
|
where: { id: roomId },
|
||||||
type: 'BOT',
|
|
||||||
botUsername: bot.username,
|
|
||||||
createdById: ownerUserId
|
|
||||||
},
|
|
||||||
include: {
|
include: {
|
||||||
members: { include: { user: { select: { linkedBotId: true } } } }
|
members: { include: { user: { select: { linkedBotId: true } } } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (!room || room.type !== 'BOT') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const recipients = new Set<string>();
|
const recipients = room.members
|
||||||
for (const room of rooms) {
|
.filter((member) => !member.user.linkedBotId)
|
||||||
for (const member of room.members) {
|
.map((member) => member.userId);
|
||||||
if (member.userId !== ownerUserId && !member.user.linkedBotId) {
|
|
||||||
recipients.add(member.userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
[...recipients].map((recipientId) => this.notifications.publishRealtime(recipientId, eventType, bot.name, message, payload))
|
recipients.map((recipientId) =>
|
||||||
|
this.notifications.publishRealtime(recipientId, eventType, bot.name, message, payload)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,22 @@ export class BotFatherAssistantService {
|
|||||||
? ownBots.map((bot) => `• ${bot.name} (@${bot.username})\n bot_id: ${bot.id}\n chat_id: ${deriveTelegramChatId(`${bot.id}:${senderUserId}`).toString()}`)
|
? ownBots.map((bot) => `• ${bot.name} (@${bot.username})\n bot_id: ${bot.id}\n chat_id: ${deriveTelegramChatId(`${bot.id}:${senderUserId}`).toString()}`)
|
||||||
: ['У вас пока нет созданных ботов.'];
|
: ['У вас пока нет созданных ботов.'];
|
||||||
|
|
||||||
|
const broadcastRooms = ownBots.length
|
||||||
|
? await this.prisma.chatRoom.findMany({
|
||||||
|
where: {
|
||||||
|
type: 'BOT',
|
||||||
|
botUsername: { in: ownBots.map((bot) => bot.username) }
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, botUsername: true }
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const broadcastLines = broadcastRooms.map((room) => {
|
||||||
|
const bot = ownBots.find((item) => item.username === room.botUsername);
|
||||||
|
if (!bot) return null;
|
||||||
|
return `• ${room.name}\n group_chat_id: ${deriveTelegramChatId(`${bot.id}:room:${room.id}`).toString()}`;
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
await this.botApi.sendInternalBotMessage(
|
await this.botApi.sendInternalBotMessage(
|
||||||
botId,
|
botId,
|
||||||
senderUserId,
|
senderUserId,
|
||||||
@@ -155,11 +171,16 @@ export class BotFatherAssistantService {
|
|||||||
'',
|
'',
|
||||||
`chat_id с ${GET_MY_ID_DISPLAY_NAME}: ${currentChatId}`,
|
`chat_id с ${GET_MY_ID_DISPLAY_NAME}: ${currentChatId}`,
|
||||||
'',
|
'',
|
||||||
'chat_id для ваших ботов:',
|
'chat_id для ваших ботов (личный диалог):',
|
||||||
...botLines,
|
...botLines,
|
||||||
'',
|
'',
|
||||||
|
broadcastLines.length ? 'group_chat_id для уведомлений в семейный чат:' : '',
|
||||||
|
...broadcastLines,
|
||||||
|
'',
|
||||||
|
'Личный chat_id — для ответов конкретному пользователю.',
|
||||||
|
'group_chat_id — для уведомлений, которые должны видеть все участники чата с ботом.',
|
||||||
'Используйте нужный chat_id в методах sendMessage, editMessageText, pinChatMessage и getChat.'
|
'Используйте нужный chat_id в методах sendMessage, editMessageText, pinChatMessage и getChat.'
|
||||||
].join('\n')
|
].filter(Boolean).join('\n')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -214,23 +214,23 @@ export class BotInboundService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async resolveSharedFamilyBotChat(botId: string, senderUserId: string, roomId: string) {
|
private async resolveSharedFamilyBotChat(botId: string, senderUserId: string, roomId: string) {
|
||||||
|
await this.assertBotRoomAccess(botId, senderUserId, roomId);
|
||||||
|
return this.ensureBotChat(botId, senderUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertBotRoomAccess(botId: string, userId: string, roomId: string) {
|
||||||
const room = await this.prisma.chatRoom.findUnique({
|
const room = await this.prisma.chatRoom.findUnique({
|
||||||
where: { id: roomId },
|
where: { id: roomId },
|
||||||
include: {
|
include: {
|
||||||
members: { include: { user: { select: { linkedBotId: true } } } }
|
members: { include: { user: { select: { linkedBotId: true } } } }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!room || room.type !== 'BOT' || !room.members.some((member) => member.userId === senderUserId)) {
|
if (!room || room.type !== 'BOT' || !room.members.some((member) => member.userId === userId)) {
|
||||||
throw new NotFoundException('Чат с ботом не найден');
|
throw new NotFoundException('Чат с ботом не найден');
|
||||||
}
|
}
|
||||||
const botMember = room.members.find((member) => member.user.linkedBotId === botId);
|
const botMember = room.members.find((member) => member.user.linkedBotId === botId);
|
||||||
if (!botMember || room.peerUserId !== botMember.userId) {
|
if (!botMember || room.peerUserId !== botMember.userId) {
|
||||||
throw new NotFoundException('Бот не найден в этом чате');
|
throw new NotFoundException('Бот не найден в этом чате');
|
||||||
}
|
}
|
||||||
const ownerUserId =
|
|
||||||
room.createdById ??
|
|
||||||
room.members.find((member) => !member.user.linkedBotId)?.userId ??
|
|
||||||
senderUserId;
|
|
||||||
return this.ensureBotChat(botId, ownerUserId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../infra/prisma.service';
|
import { PrismaService } from '../infra/prisma.service';
|
||||||
import { MinioService } from '../infra/minio.service';
|
import { MinioService } from '../infra/minio.service';
|
||||||
|
import { deriveTelegramChatId } from './bot/bot-token.util';
|
||||||
import { resolveVerificationIcon } from './verification.constants';
|
import { resolveVerificationIcon } from './verification.constants';
|
||||||
import { FamilyService } from './family.service';
|
import { FamilyService } from './family.service';
|
||||||
import { NotificationsService } from './notifications.service';
|
import { NotificationsService } from './notifications.service';
|
||||||
@@ -88,7 +89,7 @@ export class ChatService {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (room.type === 'BOT' && room.botUsername) {
|
if (room.type === 'BOT' && room.botUsername) {
|
||||||
const botPreview = await this.getBotRoomPreview(userId, room.botUsername);
|
const botPreview = await this.getBotRoomPreview(userId, room.botUsername, room.id);
|
||||||
if (botPreview) {
|
if (botPreview) {
|
||||||
lastMessage = botPreview;
|
lastMessage = botPreview;
|
||||||
}
|
}
|
||||||
@@ -1203,10 +1204,13 @@ export class ChatService {
|
|||||||
data: { botUsername, peerUserId: botUserId, name: botDisplayName }
|
data: { botUsername, peerUserId: botUserId, name: botDisplayName }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const linkedBotId =
|
||||||
|
(await this.prisma.user.findUnique({ where: { id: botUserId }, select: { linkedBotId: true } }))?.linkedBotId ?? '';
|
||||||
|
await this.ensureRoomBotChat(linkedBotId, existing.id);
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.chatRoom.create({
|
const room = await this.prisma.chatRoom.create({
|
||||||
data: {
|
data: {
|
||||||
groupId,
|
groupId,
|
||||||
type: 'BOT',
|
type: 'BOT',
|
||||||
@@ -1220,9 +1224,40 @@ export class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const linkedBotId =
|
||||||
|
(await this.prisma.user.findUnique({ where: { id: botUserId }, select: { linkedBotId: true } }))?.linkedBotId ?? '';
|
||||||
|
await this.ensureRoomBotChat(linkedBotId, room.id);
|
||||||
|
|
||||||
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getBotRoomPreview(userId: string, botUsername: string) {
|
private async ensureRoomBotChat(botId: string, roomId: string) {
|
||||||
|
if (!botId) return;
|
||||||
|
const existing = await this.prisma.botChat.findUnique({
|
||||||
|
where: { botId_roomId: { botId, roomId } }
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const telegramChatId = deriveTelegramChatId(`${botId}:room:${roomId}`);
|
||||||
|
try {
|
||||||
|
return await this.prisma.botChat.create({
|
||||||
|
data: {
|
||||||
|
botId,
|
||||||
|
roomId,
|
||||||
|
telegramChatId,
|
||||||
|
chatType: 'group',
|
||||||
|
recipientUserId: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return this.prisma.botChat.findUnique({
|
||||||
|
where: { botId_roomId: { botId, roomId } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBotRoomPreview(userId: string, botUsername: string, roomId?: string) {
|
||||||
const bot = await this.prisma.bot.findFirst({
|
const bot = await this.prisma.bot.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ username: botUsername }, { username: `${botUsername.replace(/_bot$/i, '')}_bot` }]
|
OR: [{ username: botUsername }, { username: `${botUsername.replace(/_bot$/i, '')}_bot` }]
|
||||||
@@ -1231,18 +1266,24 @@ export class ChatService {
|
|||||||
});
|
});
|
||||||
if (!bot) return undefined;
|
if (!bot) return undefined;
|
||||||
|
|
||||||
const chat = await this.prisma.botChat.findUnique({
|
const privateChat = await this.prisma.botChat.findUnique({
|
||||||
where: { botId_recipientUserId: { botId: bot.id, recipientUserId: userId } }
|
where: { botId_recipientUserId: { botId: bot.id, recipientUserId: userId } }
|
||||||
});
|
});
|
||||||
if (!chat) return undefined;
|
const groupChat = roomId ? await this.ensureRoomBotChat(bot.id, roomId) : null;
|
||||||
|
|
||||||
|
if (!privateChat && !groupChat) return undefined;
|
||||||
|
|
||||||
|
const chatIds = [privateChat?.id, groupChat?.id].filter(Boolean) as string[];
|
||||||
|
|
||||||
const [lastInbound, lastOutbound] = await Promise.all([
|
const [lastInbound, lastOutbound] = await Promise.all([
|
||||||
this.prisma.botInboundMessage.findFirst({
|
privateChat
|
||||||
where: { botChatId: chat.id },
|
? this.prisma.botInboundMessage.findFirst({
|
||||||
|
where: { botChatId: privateChat.id, senderUserId: userId },
|
||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'desc' }
|
||||||
}),
|
})
|
||||||
|
: Promise.resolve(null),
|
||||||
this.prisma.botMessage.findFirst({
|
this.prisma.botMessage.findFirst({
|
||||||
where: { botChatId: chat.id },
|
where: { botChatId: { in: chatIds } },
|
||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: 'desc' }
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
@@ -1252,8 +1293,8 @@ export class ChatService {
|
|||||||
|
|
||||||
const latest = candidates.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]!;
|
const latest = candidates.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]!;
|
||||||
return {
|
return {
|
||||||
id: `bot-preview-${chat.id}`,
|
id: `bot-preview-${privateChat?.id ?? groupChat?.id ?? bot.id}`,
|
||||||
roomId: '',
|
roomId: roomId ?? '',
|
||||||
senderId: userId,
|
senderId: userId,
|
||||||
senderName: bot.name,
|
senderName: bot.name,
|
||||||
senderHasAvatar: false,
|
senderHasAvatar: false,
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ message BotChatMessageItem {
|
|||||||
optional string replyMarkupJson = 7;
|
optional string replyMarkupJson = 7;
|
||||||
bool isPinned = 8;
|
bool isPinned = 8;
|
||||||
optional string pinnedAt = 9;
|
optional string pinnedAt = 9;
|
||||||
|
optional string scope = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ListBotChatMessagesResponse {
|
message ListBotChatMessagesResponse {
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ set -euo pipefail
|
|||||||
|
|
||||||
cd /workspace
|
cd /workspace
|
||||||
|
|
||||||
|
# 1. Настройка переменных окружения
|
||||||
export VITE_API_URL="${VITE_API_URL:-${PUBLIC_API_URL:-http://localhost:3000}}"
|
export VITE_API_URL="${VITE_API_URL:-${PUBLIC_API_URL:-http://localhost:3000}}"
|
||||||
export VITE_FRONTEND_URL="${VITE_FRONTEND_URL:-${PUBLIC_FRONTEND_URL:-http://localhost:3002}}"
|
export VITE_FRONTEND_URL="${VITE_FRONTEND_URL:-${PUBLIC_FRONTEND_URL:-http://localhost:3002}}"
|
||||||
export ANDROID_HOME="${ANDROID_HOME:-/opt/android-sdk}"
|
export ANDROID_HOME="${ANDROID_HOME:-/opt/android-sdk}"
|
||||||
export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME}}"
|
export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME}}"
|
||||||
|
|
||||||
|
# Устанавливаем целевую версию Gradle Plugin.
|
||||||
|
# Можно переопределить через Docker: -e ANDROID_GRADLE_PLUGIN_VERSION=8.5.0
|
||||||
|
export ANDROID_GRADLE_PLUGIN_VERSION="${ANDROID_GRADLE_PLUGIN_VERSION:-8.3.2}"
|
||||||
|
|
||||||
|
# Поиск NDK
|
||||||
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
|
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
|
||||||
NDK_DIR="$(find "${ANDROID_HOME}/ndk" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -n 1 || true)"
|
NDK_DIR="$(find "${ANDROID_HOME}/ndk" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V | tail -n 1 || true)"
|
||||||
if [ -n "${NDK_DIR}" ]; then
|
if [ -n "${NDK_DIR}" ]; then
|
||||||
@@ -18,19 +24,22 @@ fi
|
|||||||
|
|
||||||
echo "==> Android SDK: ${ANDROID_HOME}"
|
echo "==> Android SDK: ${ANDROID_HOME}"
|
||||||
echo "==> Android NDK: ${ANDROID_NDK_HOME:-не найден}"
|
echo "==> Android NDK: ${ANDROID_NDK_HOME:-не найден}"
|
||||||
|
echo "==> Целевая версия Android Gradle Plugin: ${ANDROID_GRADLE_PLUGIN_VERSION}"
|
||||||
|
|
||||||
if [ -z "${ANDROID_NDK_HOME:-}" ] || [ ! -d "${ANDROID_NDK_HOME}" ]; then
|
if [ -z "${ANDROID_NDK_HOME:-}" ] || [ ! -d "${ANDROID_NDK_HOME}" ]; then
|
||||||
echo "Android NDK не найден в ${ANDROID_HOME}/ndk. Используйте Android SDK image с предустановленным NDK или примонтируйте SDK с NDK." >&2
|
echo "Ошибка: Android NDK не найден." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p /out
|
mkdir -p /out
|
||||||
|
|
||||||
|
# 2. Сборка веб-части
|
||||||
echo "==> Проверка web-сборки tauri_app"
|
echo "==> Проверка web-сборки tauri_app"
|
||||||
npm --workspace @lendry/tauri-app run build
|
npm --workspace @lendry/tauri-app run build
|
||||||
|
|
||||||
cd /workspace/tauri_app
|
cd /workspace/tauri_app
|
||||||
|
|
||||||
|
# 3. Инициализация проекта
|
||||||
if [ -d "src-tauri/gen/android" ]; then
|
if [ -d "src-tauri/gen/android" ]; then
|
||||||
echo "==> Очистка предыдущего Android проекта Tauri"
|
echo "==> Очистка предыдущего Android проекта Tauri"
|
||||||
rm -rf src-tauri/gen/android
|
rm -rf src-tauri/gen/android
|
||||||
@@ -39,28 +48,29 @@ fi
|
|||||||
echo "==> Инициализация Android проекта Tauri"
|
echo "==> Инициализация Android проекта Tauri"
|
||||||
npm run android:init -- --ci || npm run android:init
|
npm run android:init -- --ci || npm run android:init
|
||||||
|
|
||||||
if [ -n "${ANDROID_GRADLE_PLUGIN_VERSION:-}" ]; then
|
# 4. Принудительный патчинг всех файлов конфигурации Gradle
|
||||||
echo "==> Переопределение Android Gradle Plugin: ${ANDROID_GRADLE_PLUGIN_VERSION}"
|
# Мы заменяем любые упоминания версии плагина на нашу стабильную версию
|
||||||
find src-tauri/gen/android \
|
echo "==> Патчинг конфигурации Gradle на версию ${ANDROID_GRADLE_PLUGIN_VERSION}"
|
||||||
-type f \
|
|
||||||
\( -name '*.gradle' -o -name '*.gradle.kts' -o -name '*.toml' \) \
|
|
||||||
-print0 \
|
|
||||||
| xargs -0 sed -i -E "s/(com\\.android\\.tools\\.build:gradle:)[0-9]+\\.[0-9]+\\.[0-9]+/\\1${ANDROID_GRADLE_PLUGIN_VERSION}/g; s/(com\\.android\\.application[\"']? version[[:space:]]*=?[[:space:]]*[\"'])[0-9]+\\.[0-9]+\\.[0-9]+/\\1${ANDROID_GRADLE_PLUGIN_VERSION}/g"
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
find src-tauri/gen/android -type f \( -name "*.gradle" -o -name "*.gradle.kts" -o -name "*.toml" \) -print0 | xargs -0 sed -i -E \
|
||||||
|
-e "s/(com\.android\.tools\.build:gradle:)[0-9]+\.[0-9]+\.[0-9]+/\1${ANDROID_GRADLE_PLUGIN_VERSION}/g" \
|
||||||
|
-e "s/(id\(?['\"]com\.android\.application['\"]? version ['\"])[0-9]+\.[0-9]+\.[0-9]+/\1${ANDROID_GRADLE_PLUGIN_VERSION}/g" \
|
||||||
|
-e "s/(agp[[:space:]]*=[[:space:]]*['\"])[0-9]+\.[0-9]+\.[0-9]+/\1${ANDROID_GRADLE_PLUGIN_VERSION}/g"
|
||||||
|
|
||||||
|
# 5. Сборка APK
|
||||||
echo "==> Сборка Android APK"
|
echo "==> Сборка Android APK"
|
||||||
npm run tauri -- android build --apk
|
npm run tauri -- android build --apk
|
||||||
|
|
||||||
|
# 6. Копирование результата
|
||||||
APK_PATH="$(find /workspace/tauri_app/src-tauri/gen/android -type f -name '*.apk' | sort | tail -n 1 || true)"
|
APK_PATH="$(find /workspace/tauri_app/src-tauri/gen/android -type f -name '*.apk' | sort | tail -n 1 || true)"
|
||||||
|
|
||||||
if [ -z "${APK_PATH}" ] || [ ! -f "${APK_PATH}" ]; then
|
if [ -z "${APK_PATH}" ] || [ ! -f "${APK_PATH}" ]; then
|
||||||
echo "APK не найден после сборки" >&2
|
echo "Ошибка: APK не найден после сборки" >&2
|
||||||
find /workspace/tauri_app/src-tauri/gen/android -maxdepth 8 -type f | sort >&2 || true
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cp "${APK_PATH}" /out/lendry-id.apk
|
cp "${APK_PATH}" /out/lendry-id.apk
|
||||||
chmod 0644 /out/lendry-id.apk
|
chmod 0644 /out/lendry-id.apk
|
||||||
|
|
||||||
echo "==> APK готов: /out/lendry-id.apk"
|
echo "==> APK успешно собран: /out/lendry-id.apk"
|
||||||
ls -lh /out/lendry-id.apk
|
ls -lh /out/lendry-id.apk
|
||||||
Reference in New Issue
Block a user