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

View File

@@ -7,6 +7,13 @@ import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat';
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
import { ChatDateSeparator } from '@/components/chat/chat-date-separator';
import {
ChatMediaComposeDialog,
ChatMediaDropOverlay,
extractFilesFromClipboard,
extractFilesFromDataTransfer,
useChatMediaComposer
} from '@/components/chat/chat-media-compose-dialog';
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
@@ -58,6 +65,7 @@ import {
isSystemMessage
} from '@/lib/chat-message-utils';
import { detectChatMessageType, parseMessageMetadata, resolveChatContentType } from '@/lib/chat-media';
import { uploadChatMediaBatch } from '@/lib/chat-media-upload';
import { getSingleEmojiNative, isSingleEmojiMessage } from '@/lib/chat-emoji-display';
import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils';
import { encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat';
@@ -122,9 +130,12 @@ export function MiniFamilyChat() {
const [pollOptions, setPollOptions] = useState(['', '']);
const [chatToolsOpen, setChatToolsOpen] = useState(false);
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
const [mediaDropVisible, setMediaDropVisible] = useState(false);
const mediaComposer = useChatMediaComposer();
const messagesEndRef = useRef<HTMLDivElement>(null);
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const previousGroupIdRef = useRef<string | null>(null);
const visibleMessages = useMemo(
@@ -580,65 +591,66 @@ export function MiniFamilyChat() {
}
async function uploadChatFile(file: File) {
if (!token || !activeRoom || sending) return;
queueMediaFiles([file]);
}
const queueMediaFiles = useCallback(
(files: File[], options?: { sendAsFile?: boolean }) => {
if (!activeRoom || view !== 'chat' || !files.length) return;
if (files.length > mediaComposer.maxFiles) {
showToast(`Можно отправить не более ${mediaComposer.maxFiles} файлов за раз`);
}
const limited = files.slice(0, mediaComposer.maxFiles);
if (mediaComposer.open && mediaComposer.items.length) {
void mediaComposer.addFiles(limited);
return;
}
void mediaComposer.openWithFiles(limited, options);
},
[activeRoom, mediaComposer, view]
);
async function submitMediaCompose() {
if (!token || !activeRoom || !mediaComposer.items.length) return;
setSending(true);
try {
const contentType = resolveChatContentType(file);
const messageType = detectChatMessageType(file);
const metadata = { fileName: file.name, fileSize: file.size };
let uploadFile = file;
let uploadContentType = contentType;
let messageContent: string | undefined = messageType === 'FILE' ? file.name : undefined;
let isEncrypted = false;
if (activeRoomIsE2E) {
if (!user?.id || !peerE2eKey) {
showToast('Секретный чат недоступен без ключей E2E у обоих участников');
return;
}
const encryptedBuffer = await encryptE2EBlob(user.id, peerE2eKey, await file.arrayBuffer());
uploadFile = new File([encryptedBuffer], `${file.name}.lendryenc`, { type: 'application/octet-stream' });
uploadContentType = 'application/octet-stream';
messageContent = await encryptOutgoing({
kind: e2eKindFromMessageType(messageType),
fileName: file.name,
mimeType: contentType,
fileSize: file.size
});
isEncrypted = true;
}
const presigned = await apiFetch<PresignedUploadResponse>(
`/media/chat/${activeRoom.id}/media/upload-url`,
{
method: 'POST',
body: JSON.stringify({ contentType: uploadContentType, fileName: uploadFile.name })
},
token
);
await uploadMediaObject(presigned, uploadFile, uploadContentType);
const message = await sendChatMessage(
activeRoom.id,
{
type: messageType,
storageKey: presigned.storageKey,
mimeType: uploadContentType,
content: messageContent,
isEncrypted,
metadataJson: JSON.stringify(activeRoomIsE2E ? { e2e: true, ...metadata } : metadata)
},
token
);
setMessages((current) => [...current, message]);
const uploaded = await uploadChatMediaBatch({
roomId: activeRoom.id,
token,
items: mediaComposer.items.map((item) => ({
file: item.file,
sendAsFile: item.sendAsFile,
width: item.width,
height: item.height
})),
caption: mediaComposer.caption,
isE2E: activeRoomIsE2E,
userId: user?.id,
peerE2eKey,
encryptOutgoing
});
setMessages((current) => [...current, ...uploaded]);
mediaComposer.close();
void loadRooms();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить файл') ?? 'Ошибка');
showToast(getApiErrorMessage(error, 'Не удалось отправить файлы') ?? 'Ошибка');
} finally {
setSending(false);
}
}
useEffect(() => {
if (!activeRoom || view !== 'chat') return;
function handlePaste(event: ClipboardEvent) {
const files = extractFilesFromClipboard(event.clipboardData);
if (!files.length) return;
event.preventDefault();
queueMediaFiles(files, { sendAsFile: false });
}
window.addEventListener('paste', handlePaste);
return () => window.removeEventListener('paste', handlePaste);
}, [activeRoom, queueMediaFiles, view]);
async function submitPoll() {
if (!token || !activeRoom) return;
const options = pollOptions.map((item) => item.trim()).filter(Boolean);
@@ -946,7 +958,40 @@ export function MiniFamilyChat() {
}}
/>
) : (
<>
<div
className="relative flex min-h-0 flex-1 flex-col"
onDragEnter={(event) => {
event.preventDefault();
dragDepthRef.current += 1;
setMediaDropVisible(true);
}}
onDragLeave={(event) => {
event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setMediaDropVisible(false);
}}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
dragDepthRef.current = 0;
setMediaDropVisible(false);
const files = extractFilesFromDataTransfer(event.dataTransfer);
if (files.length) queueMediaFiles(files, { sendAsFile: false });
}}
>
<ChatMediaDropOverlay
visible={mediaDropVisible}
onDropAsFile={(files) => {
dragDepthRef.current = 0;
setMediaDropVisible(false);
queueMediaFiles(files, { sendAsFile: true });
}}
onDropCompressed={(files) => {
dragDepthRef.current = 0;
setMediaDropVisible(false);
queueMediaFiles(files, { sendAsFile: false });
}}
/>
{pinnedMessage ? (
<ChatPinnedMessageBanner
senderName={pinnedMessage.senderName}
@@ -1268,7 +1313,7 @@ export function MiniFamilyChat() {
</div>
</div>
)}
</>
</div>
)}
</div>
) : null}
@@ -1335,14 +1380,30 @@ export function MiniFamilyChat() {
<input
ref={fileInputRef}
type="file"
accept="image/*,audio/*,video/*,.pdf,.doc,.docx,.txt,.zip"
multiple
accept="image/*,audio/*,video/*,.pdf,.doc,.docx,.txt,.zip,.rar,.7z"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void uploadChatFile(file);
const files = Array.from(event.target.files ?? []);
if (files.length) queueMediaFiles(files);
event.target.value = '';
}}
/>
<ChatMediaComposeDialog
open={mediaComposer.open}
items={mediaComposer.items}
caption={mediaComposer.caption}
sendAsFile={mediaComposer.sendAsFile}
hasImages={mediaComposer.hasImages}
maxFiles={mediaComposer.maxFiles}
sending={sending}
onCaptionChange={mediaComposer.setCaption}
onSendAsFileChange={mediaComposer.setSendAsFile}
onRemoveItem={mediaComposer.removeItem}
onAddFiles={(files) => void mediaComposer.addFiles(files)}
onClose={mediaComposer.close}
onSend={() => void submitMediaCompose()}
/>
<BotMiniAppSheet
url={miniAppUrl}
title={miniAppTitle}