update
This commit is contained in:
@@ -135,6 +135,16 @@ export function FamilyBotChat({
|
||||
return;
|
||||
}
|
||||
|
||||
const eventScope = payload.scope === 'broadcast' ? 'broadcast' : 'private';
|
||||
const eventRoomId = typeof payload.roomId === 'string' ? payload.roomId : null;
|
||||
if (eventScope === 'broadcast') {
|
||||
if (roomId && eventRoomId && eventRoomId !== roomId) {
|
||||
return;
|
||||
}
|
||||
} else if (roomId) {
|
||||
// Личные сообщения бота не транслируются другим участникам комнаты.
|
||||
}
|
||||
|
||||
if (event.type === 'bot_message') {
|
||||
const messageId = Number(payload.messageId);
|
||||
if (!Number.isFinite(messageId)) {
|
||||
@@ -144,8 +154,9 @@ export function FamilyBotChat({
|
||||
|
||||
const replyMarkupJson = markupJsonFromPayload(payload);
|
||||
const outbound: BotChatMessage = {
|
||||
id: `out-ws-${messageId}`,
|
||||
id: `${eventScope === 'broadcast' ? 'broadcast' : 'out'}-ws-${messageId}`,
|
||||
direction: 'out',
|
||||
scope: eventScope,
|
||||
text: typeof payload.text === 'string' ? payload.text : '',
|
||||
messageType: typeof payload.messageType === 'string' ? payload.messageType : 'text',
|
||||
messageId,
|
||||
@@ -156,7 +167,9 @@ export function FamilyBotChat({
|
||||
};
|
||||
|
||||
setMessages((current) => {
|
||||
const withoutDuplicate = current.filter((item) => item.messageId !== messageId || item.direction !== 'out');
|
||||
const withoutDuplicate = current.filter(
|
||||
(item) => item.messageId !== messageId || item.direction !== 'out' || item.scope !== eventScope
|
||||
);
|
||||
return [...withoutDuplicate, outbound].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
});
|
||||
return;
|
||||
@@ -191,7 +204,7 @@ export function FamilyBotChat({
|
||||
const replyMarkupJson = markupJsonFromPayload(payload);
|
||||
setMessages((current) =>
|
||||
current.map((item) =>
|
||||
item.messageId === messageId && item.direction === 'out'
|
||||
item.messageId === messageId && item.direction === 'out' && (item.scope ?? 'private') === eventScope
|
||||
? {
|
||||
...item,
|
||||
text: typeof payload.text === 'string' ? payload.text : item.text,
|
||||
@@ -212,7 +225,7 @@ export function FamilyBotChat({
|
||||
|
||||
setMessages((current) =>
|
||||
current.map((item) =>
|
||||
item.messageId === messageId && item.direction === 'out'
|
||||
item.messageId === messageId && item.direction === 'out' && (item.scope ?? 'private') === eventScope
|
||||
? {
|
||||
...item,
|
||||
isPinned: Boolean(payload.isPinned),
|
||||
@@ -223,7 +236,7 @@ export function FamilyBotChat({
|
||||
);
|
||||
}
|
||||
});
|
||||
}, [botRef, loadMessages, subscribe]);
|
||||
}, [botRef, loadMessages, roomId, subscribe]);
|
||||
|
||||
async function handleSend() {
|
||||
if (!token || !draft.trim() || sending) return;
|
||||
@@ -271,12 +284,18 @@ export function FamilyBotChat({
|
||||
) : messages.length ? (
|
||||
messages.map((message) => {
|
||||
const mine = message.direction === 'in';
|
||||
const isBroadcast = message.scope === 'broadcast';
|
||||
const hasKeyboard = Boolean(message.replyMarkupJson?.trim());
|
||||
return (
|
||||
<div key={message.id} id={`bot-msg-${message.id}`} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
|
||||
<BotChatMessageContextMenu text={message.text} onCopy={() => showToast('Текст скопирован')}>
|
||||
<div className={cn('max-w-[78%] rounded-[18px] px-3 py-2 shadow-sm', mine ? 'rounded-br-md bg-[#effdde]' : 'rounded-bl-md bg-white')}>
|
||||
{!mine ? <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{botName}</p> : null}
|
||||
{!mine ? (
|
||||
<p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">
|
||||
{botName}
|
||||
{isBroadcast ? ' · уведомление' : ''}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="whitespace-pre-wrap text-sm">{message.text || '…'}</p>
|
||||
{!mine && hasKeyboard && message.messageId > 0 ? (
|
||||
<BotMessageKeyboard
|
||||
|
||||
@@ -34,6 +34,15 @@ import { BotMiniAppSheet, FamilyBotChat } from '@/components/family/family-bot-c
|
||||
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
|
||||
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
|
||||
import { ChatForwardDialog } from '@/components/chat/chat-forward-dialog';
|
||||
import { ChatImageLightbox } from '@/components/chat/chat-image-lightbox';
|
||||
import { ChatMediaAlbumGrid } from '@/components/chat/chat-media-album-grid';
|
||||
import {
|
||||
ChatMediaComposeDialog,
|
||||
ChatMediaDropOverlay,
|
||||
extractFilesFromClipboard,
|
||||
extractFilesFromDataTransfer,
|
||||
useChatMediaComposer
|
||||
} from '@/components/chat/chat-media-compose-dialog';
|
||||
import { ChatDateSeparator } from '@/components/chat/chat-date-separator';
|
||||
import { ChatToolsDialog, type ChatToolsTab } from '@/components/chat/chat-tools-dialog';
|
||||
import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
|
||||
@@ -126,6 +135,8 @@ import {
|
||||
parseMessageMetadata,
|
||||
resolveChatContentType
|
||||
} from '@/lib/chat-media';
|
||||
import { collectChatImageMessages, parseChatMediaMetadata } from '@/lib/chat-media-album';
|
||||
import { uploadChatMediaBatch } from '@/lib/chat-media-upload';
|
||||
|
||||
interface PresignedUploadResponse {
|
||||
uploadUrl: string;
|
||||
@@ -211,6 +222,31 @@ function familyMemberRoleLabel(role: string) {
|
||||
return role === 'owner' ? 'Создатель семьи' : 'Участник';
|
||||
}
|
||||
|
||||
function shouldSkipGroupedAlbumMessage(messages: ChatMessage[], index: number) {
|
||||
const message = messages[index];
|
||||
if (!message) return false;
|
||||
const meta = parseChatMediaMetadata(message.metadataJson);
|
||||
if (!meta.albumId) return false;
|
||||
const previous = messages[index - 1];
|
||||
if (!previous) return false;
|
||||
const previousMeta = parseChatMediaMetadata(previous.metadataJson);
|
||||
return previous.senderId === message.senderId && previousMeta.albumId === meta.albumId;
|
||||
}
|
||||
|
||||
function collectAlbumMessages(messages: ChatMessage[], startIndex: number) {
|
||||
const first = messages[startIndex]!;
|
||||
const albumId = parseChatMediaMetadata(first.metadataJson).albumId;
|
||||
if (!albumId) return [first];
|
||||
const result: ChatMessage[] = [];
|
||||
for (let index = startIndex; index < messages.length; index += 1) {
|
||||
const message = messages[index]!;
|
||||
const meta = parseChatMediaMetadata(message.metadataJson);
|
||||
if (message.senderId !== first.senderId || meta.albumId !== albumId) break;
|
||||
result.push(message);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const router = useRouter();
|
||||
const { user, token } = useAuth();
|
||||
@@ -258,6 +294,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const [replyToMessage, setReplyToMessage] = useState<ChatMessage | null>(null);
|
||||
const [chatToolsOpen, setChatToolsOpen] = useState(false);
|
||||
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
||||
const [mediaDropVisible, setMediaDropVisible] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
const mediaComposer = useChatMediaComposer();
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
|
||||
@@ -268,6 +308,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const recordingStartedAtRef = useRef<number | null>(null);
|
||||
const mediaUrlsRef = useRef<Record<string, LoadedChatMedia>>({});
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
mediaUrlsRef.current = mediaUrls;
|
||||
@@ -279,6 +320,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}, []);
|
||||
|
||||
const visibleMessages = useMemo(() => messages.filter((message) => !message.isDeleted), [messages]);
|
||||
const chatImageMessages = useMemo(() => collectChatImageMessages(visibleMessages), [visibleMessages]);
|
||||
const messageById = useMemo(
|
||||
() => new Map(visibleMessages.map((message) => [message.id, message])),
|
||||
[visibleMessages]
|
||||
@@ -1127,6 +1169,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
|
||||
async function uploadChatFile(file: File, options?: { voice?: boolean; durationMs?: number }) {
|
||||
if (!token || !activeRoomId || !activeRoom) return;
|
||||
if (!options?.voice) {
|
||||
queueMediaFiles([file]);
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
const contentType = resolveChatContentType(file);
|
||||
@@ -1191,6 +1237,68 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
const queueMediaFiles = useCallback(
|
||||
(files: File[], options?: { sendAsFile?: boolean }) => {
|
||||
if (!activeRoom || activeRoomIsBot || !files.length) return;
|
||||
if (files.length > mediaComposer.maxFiles) {
|
||||
showToast(`Можно отправить не более ${mediaComposer.maxFiles} файлов за раз`);
|
||||
}
|
||||
const limited = files.slice(0, mediaComposer.maxFiles);
|
||||
if (mediaComposer.open && mediaComposer.items.length) {
|
||||
void mediaComposer.addFiles(limited);
|
||||
return;
|
||||
}
|
||||
void mediaComposer.openWithFiles(limited, options);
|
||||
},
|
||||
[activeRoom, activeRoomIsBot, mediaComposer]
|
||||
);
|
||||
|
||||
async function submitMediaCompose() {
|
||||
if (!token || !activeRoomId || !activeRoom || !mediaComposer.items.length) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const uploaded = await uploadChatMediaBatch({
|
||||
roomId: activeRoomId,
|
||||
token,
|
||||
items: mediaComposer.items.map((item) => ({
|
||||
file: item.file,
|
||||
sendAsFile: item.sendAsFile,
|
||||
width: item.width,
|
||||
height: item.height
|
||||
})),
|
||||
caption: mediaComposer.caption,
|
||||
isE2E: activeRoomIsE2E,
|
||||
userId: user?.id,
|
||||
peerE2eKey,
|
||||
encryptOutgoing
|
||||
});
|
||||
uploaded.forEach((message) => appendMessage(message));
|
||||
mediaComposer.close();
|
||||
void loadGroup();
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось отправить файлы') ?? 'Ошибка');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRoom || activeRoomIsBot) return;
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
const files = extractFilesFromClipboard(event.clipboardData);
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
queueMediaFiles(files, { sendAsFile: false });
|
||||
}
|
||||
window.addEventListener('paste', handlePaste);
|
||||
return () => window.removeEventListener('paste', handlePaste);
|
||||
}, [activeRoom, activeRoomIsBot, queueMediaFiles]);
|
||||
|
||||
function openImageLightbox(message: ChatMessage) {
|
||||
const index = chatImageMessages.findIndex((item) => item.id === message.id);
|
||||
if (index >= 0) setLightboxIndex(index);
|
||||
}
|
||||
|
||||
async function submitPoll() {
|
||||
if (!token || !activeRoomId) return;
|
||||
const options = pollOptions.map((item) => item.trim()).filter(Boolean);
|
||||
@@ -1526,7 +1634,46 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="relative flex min-h-0 flex-1 flex-col"
|
||||
onDragEnter={(event) => {
|
||||
if (activeRoomIsBot) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setMediaDropVisible(true);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (activeRoomIsBot) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setMediaDropVisible(false);
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (activeRoomIsBot) return;
|
||||
event.preventDefault();
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (activeRoomIsBot) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setMediaDropVisible(false);
|
||||
const files = extractFilesFromDataTransfer(event.dataTransfer);
|
||||
if (files.length) queueMediaFiles(files, { sendAsFile: false });
|
||||
}}
|
||||
>
|
||||
<ChatMediaDropOverlay
|
||||
visible={mediaDropVisible && !activeRoomIsBot}
|
||||
onDropAsFile={(files) => {
|
||||
dragDepthRef.current = 0;
|
||||
setMediaDropVisible(false);
|
||||
queueMediaFiles(files, { sendAsFile: true });
|
||||
}}
|
||||
onDropCompressed={(files) => {
|
||||
dragDepthRef.current = 0;
|
||||
setMediaDropVisible(false);
|
||||
queueMediaFiles(files, { sendAsFile: false });
|
||||
}}
|
||||
/>
|
||||
{pinnedMessage ? (
|
||||
<ChatPinnedMessageBanner
|
||||
senderName={pinnedMessage.senderName}
|
||||
@@ -1540,6 +1687,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
) : null}
|
||||
<div className={cn('flex-1 space-y-2 overflow-y-auto px-4 py-4', isDragging && 'select-none')}>
|
||||
{visibleMessages.map((message, messageIndex) => {
|
||||
if (shouldSkipGroupedAlbumMessage(visibleMessages, messageIndex)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousMessage = visibleMessages[messageIndex - 1];
|
||||
const showDateSeparator = shouldShowChatDateSeparator(message.createdAt, previousMessage?.createdAt);
|
||||
|
||||
@@ -1591,6 +1742,8 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
const largeEmojiNative = isLargeEmoji
|
||||
? getSingleEmojiNative({ type: message.type, content: emojiContent ?? message.content, visibleText })
|
||||
: '';
|
||||
const albumMessages = collectAlbumMessages(visibleMessages, messageIndex);
|
||||
const isAlbumGroup = albumMessages.length > 1 && Boolean(parseChatMediaMetadata(message.metadataJson).albumId);
|
||||
const bubble = (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1642,6 +1795,22 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
</div>
|
||||
) : message.storageKey ? (
|
||||
(() => {
|
||||
if (isAlbumGroup) {
|
||||
const captionMessage = albumMessages.find((item) => item.content?.trim());
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<ChatMediaAlbumGrid
|
||||
messages={albumMessages}
|
||||
mediaUrls={mediaUrls}
|
||||
onImageClick={(item) => openImageLightbox(item)}
|
||||
/>
|
||||
{captionMessage?.content ? (
|
||||
<p className="whitespace-pre-wrap text-sm">{captionMessage.content}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const media = mediaUrls[message.storageKey];
|
||||
const meta = parseMessageMetadata(message.metadataJson);
|
||||
const fileName = e2ePayload?.fileName || meta.fileName || message.content || 'Файл';
|
||||
@@ -1655,8 +1824,10 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
}
|
||||
if (message.type === 'IMAGE') {
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={media.blobUrl} alt={fileName} className="max-h-72 max-w-full rounded-xl object-cover" />
|
||||
<button type="button" className="block overflow-hidden rounded-xl" onClick={() => openImageLightbox(message)}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={media.blobUrl} alt={fileName} className="max-h-72 max-w-full object-cover" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (message.type === 'VOICE' || message.type === 'AUDIO') {
|
||||
@@ -1933,7 +2104,7 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
@@ -1941,11 +2112,44 @@ export function FamilyGroupView({ groupId }: { groupId: string }) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<input ref={fileInputRef} type="file" className="hidden" onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void uploadChatFile(file);
|
||||
event.target.value = '';
|
||||
}} />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.zip,.rar,.7z,.txt"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length) queueMediaFiles(files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChatMediaComposeDialog
|
||||
open={mediaComposer.open}
|
||||
items={mediaComposer.items}
|
||||
caption={mediaComposer.caption}
|
||||
sendAsFile={mediaComposer.sendAsFile}
|
||||
hasImages={mediaComposer.hasImages}
|
||||
maxFiles={mediaComposer.maxFiles}
|
||||
sending={sending}
|
||||
onCaptionChange={mediaComposer.setCaption}
|
||||
onSendAsFileChange={mediaComposer.setSendAsFile}
|
||||
onRemoveItem={mediaComposer.removeItem}
|
||||
onAddFiles={(files) => void mediaComposer.addFiles(files)}
|
||||
onClose={mediaComposer.close}
|
||||
onSend={() => void submitMediaCompose()}
|
||||
/>
|
||||
|
||||
<ChatImageLightbox
|
||||
open={lightboxIndex !== null}
|
||||
images={chatImageMessages}
|
||||
index={lightboxIndex ?? 0}
|
||||
mediaUrls={mediaUrls}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onIndexChange={setLightboxIndex}
|
||||
onDownload={(message) => void downloadChatFile(message)}
|
||||
/>
|
||||
|
||||
<FamilyInviteDialog
|
||||
groupId={groupId}
|
||||
|
||||
@@ -7,6 +7,13 @@ import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
|
||||
import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat';
|
||||
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
|
||||
import { ChatDateSeparator } from '@/components/chat/chat-date-separator';
|
||||
import {
|
||||
ChatMediaComposeDialog,
|
||||
ChatMediaDropOverlay,
|
||||
extractFilesFromClipboard,
|
||||
extractFilesFromDataTransfer,
|
||||
useChatMediaComposer
|
||||
} from '@/components/chat/chat-media-compose-dialog';
|
||||
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
|
||||
import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
|
||||
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
|
||||
@@ -58,6 +65,7 @@ import {
|
||||
isSystemMessage
|
||||
} from '@/lib/chat-message-utils';
|
||||
import { detectChatMessageType, parseMessageMetadata, resolveChatContentType } from '@/lib/chat-media';
|
||||
import { uploadChatMediaBatch } from '@/lib/chat-media-upload';
|
||||
import { getSingleEmojiNative, isSingleEmojiMessage } from '@/lib/chat-emoji-display';
|
||||
import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils';
|
||||
import { encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat';
|
||||
@@ -122,9 +130,12 @@ export function MiniFamilyChat() {
|
||||
const [pollOptions, setPollOptions] = useState(['', '']);
|
||||
const [chatToolsOpen, setChatToolsOpen] = useState(false);
|
||||
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
|
||||
const [mediaDropVisible, setMediaDropVisible] = useState(false);
|
||||
const mediaComposer = useChatMediaComposer();
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
const previousGroupIdRef = useRef<string | null>(null);
|
||||
|
||||
const visibleMessages = useMemo(
|
||||
@@ -580,65 +591,66 @@ export function MiniFamilyChat() {
|
||||
}
|
||||
|
||||
async function uploadChatFile(file: File) {
|
||||
if (!token || !activeRoom || sending) return;
|
||||
queueMediaFiles([file]);
|
||||
}
|
||||
|
||||
const queueMediaFiles = useCallback(
|
||||
(files: File[], options?: { sendAsFile?: boolean }) => {
|
||||
if (!activeRoom || view !== 'chat' || !files.length) return;
|
||||
if (files.length > mediaComposer.maxFiles) {
|
||||
showToast(`Можно отправить не более ${mediaComposer.maxFiles} файлов за раз`);
|
||||
}
|
||||
const limited = files.slice(0, mediaComposer.maxFiles);
|
||||
if (mediaComposer.open && mediaComposer.items.length) {
|
||||
void mediaComposer.addFiles(limited);
|
||||
return;
|
||||
}
|
||||
void mediaComposer.openWithFiles(limited, options);
|
||||
},
|
||||
[activeRoom, mediaComposer, view]
|
||||
);
|
||||
|
||||
async function submitMediaCompose() {
|
||||
if (!token || !activeRoom || !mediaComposer.items.length) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const contentType = resolveChatContentType(file);
|
||||
const messageType = detectChatMessageType(file);
|
||||
const metadata = { fileName: file.name, fileSize: file.size };
|
||||
|
||||
let uploadFile = file;
|
||||
let uploadContentType = contentType;
|
||||
let messageContent: string | undefined = messageType === 'FILE' ? file.name : undefined;
|
||||
let isEncrypted = false;
|
||||
|
||||
if (activeRoomIsE2E) {
|
||||
if (!user?.id || !peerE2eKey) {
|
||||
showToast('Секретный чат недоступен без ключей E2E у обоих участников');
|
||||
return;
|
||||
}
|
||||
const encryptedBuffer = await encryptE2EBlob(user.id, peerE2eKey, await file.arrayBuffer());
|
||||
uploadFile = new File([encryptedBuffer], `${file.name}.lendryenc`, { type: 'application/octet-stream' });
|
||||
uploadContentType = 'application/octet-stream';
|
||||
messageContent = await encryptOutgoing({
|
||||
kind: e2eKindFromMessageType(messageType),
|
||||
fileName: file.name,
|
||||
mimeType: contentType,
|
||||
fileSize: file.size
|
||||
});
|
||||
isEncrypted = true;
|
||||
}
|
||||
|
||||
const presigned = await apiFetch<PresignedUploadResponse>(
|
||||
`/media/chat/${activeRoom.id}/media/upload-url`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ contentType: uploadContentType, fileName: uploadFile.name })
|
||||
},
|
||||
token
|
||||
);
|
||||
await uploadMediaObject(presigned, uploadFile, uploadContentType);
|
||||
const message = await sendChatMessage(
|
||||
activeRoom.id,
|
||||
{
|
||||
type: messageType,
|
||||
storageKey: presigned.storageKey,
|
||||
mimeType: uploadContentType,
|
||||
content: messageContent,
|
||||
isEncrypted,
|
||||
metadataJson: JSON.stringify(activeRoomIsE2E ? { e2e: true, ...metadata } : metadata)
|
||||
},
|
||||
token
|
||||
);
|
||||
setMessages((current) => [...current, message]);
|
||||
const uploaded = await uploadChatMediaBatch({
|
||||
roomId: activeRoom.id,
|
||||
token,
|
||||
items: mediaComposer.items.map((item) => ({
|
||||
file: item.file,
|
||||
sendAsFile: item.sendAsFile,
|
||||
width: item.width,
|
||||
height: item.height
|
||||
})),
|
||||
caption: mediaComposer.caption,
|
||||
isE2E: activeRoomIsE2E,
|
||||
userId: user?.id,
|
||||
peerE2eKey,
|
||||
encryptOutgoing
|
||||
});
|
||||
setMessages((current) => [...current, ...uploaded]);
|
||||
mediaComposer.close();
|
||||
void loadRooms();
|
||||
} catch (error) {
|
||||
showToast(getApiErrorMessage(error, 'Не удалось отправить файл') ?? 'Ошибка');
|
||||
showToast(getApiErrorMessage(error, 'Не удалось отправить файлы') ?? 'Ошибка');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeRoom || view !== 'chat') return;
|
||||
function handlePaste(event: ClipboardEvent) {
|
||||
const files = extractFilesFromClipboard(event.clipboardData);
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
queueMediaFiles(files, { sendAsFile: false });
|
||||
}
|
||||
window.addEventListener('paste', handlePaste);
|
||||
return () => window.removeEventListener('paste', handlePaste);
|
||||
}, [activeRoom, queueMediaFiles, view]);
|
||||
|
||||
async function submitPoll() {
|
||||
if (!token || !activeRoom) return;
|
||||
const options = pollOptions.map((item) => item.trim()).filter(Boolean);
|
||||
@@ -946,7 +958,40 @@ export function MiniFamilyChat() {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="relative flex min-h-0 flex-1 flex-col"
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setMediaDropVisible(true);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) setMediaDropVisible(false);
|
||||
}}
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setMediaDropVisible(false);
|
||||
const files = extractFilesFromDataTransfer(event.dataTransfer);
|
||||
if (files.length) queueMediaFiles(files, { sendAsFile: false });
|
||||
}}
|
||||
>
|
||||
<ChatMediaDropOverlay
|
||||
visible={mediaDropVisible}
|
||||
onDropAsFile={(files) => {
|
||||
dragDepthRef.current = 0;
|
||||
setMediaDropVisible(false);
|
||||
queueMediaFiles(files, { sendAsFile: true });
|
||||
}}
|
||||
onDropCompressed={(files) => {
|
||||
dragDepthRef.current = 0;
|
||||
setMediaDropVisible(false);
|
||||
queueMediaFiles(files, { sendAsFile: false });
|
||||
}}
|
||||
/>
|
||||
{pinnedMessage ? (
|
||||
<ChatPinnedMessageBanner
|
||||
senderName={pinnedMessage.senderName}
|
||||
@@ -1268,7 +1313,7 @@ export function MiniFamilyChat() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1335,14 +1380,30 @@ export function MiniFamilyChat() {
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,audio/*,video/*,.pdf,.doc,.docx,.txt,.zip"
|
||||
multiple
|
||||
accept="image/*,audio/*,video/*,.pdf,.doc,.docx,.txt,.zip,.rar,.7z"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void uploadChatFile(file);
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
if (files.length) queueMediaFiles(files);
|
||||
event.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<ChatMediaComposeDialog
|
||||
open={mediaComposer.open}
|
||||
items={mediaComposer.items}
|
||||
caption={mediaComposer.caption}
|
||||
sendAsFile={mediaComposer.sendAsFile}
|
||||
hasImages={mediaComposer.hasImages}
|
||||
maxFiles={mediaComposer.maxFiles}
|
||||
sending={sending}
|
||||
onCaptionChange={mediaComposer.setCaption}
|
||||
onSendAsFileChange={mediaComposer.setSendAsFile}
|
||||
onRemoveItem={mediaComposer.removeItem}
|
||||
onAddFiles={(files) => void mediaComposer.addFiles(files)}
|
||||
onClose={mediaComposer.close}
|
||||
onSend={() => void submitMediaCompose()}
|
||||
/>
|
||||
<BotMiniAppSheet
|
||||
url={miniAppUrl}
|
||||
title={miniAppTitle}
|
||||
|
||||
Reference in New Issue
Block a user