update
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user