'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { ArrowLeft, BarChart3, CalendarDays, ImageIcon, Loader2, MessageCircle, Paperclip, Search, Send, Settings, Smile, Trash2, X } from 'lucide-react'; 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 { ChatImageLightbox } from '@/components/chat/chat-image-lightbox'; 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'; import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog'; import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner'; import { ChatMessageReactions } from '@/components/chat/chat-message-reactions'; import { ChatSelectionToolbar } from '@/components/chat/chat-selection-toolbar'; import { ChatToolsDialog, type ChatToolsTab } from '@/components/chat/chat-tools-dialog'; import { EmojiPicker } from '@/components/chat/emoji-picker'; import { scrollToChatMessage } from '@/components/chat/replied-message-preview'; import { useFamilyOverlay } from '@/components/family/family-overlay-provider'; import { useAuth } from '@/components/id/auth-provider'; import { UserAvatar } from '@/components/id/user-avatar'; import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton'; import { useToast } from '@/components/id/toast-provider'; import { useRealtime } from '@/components/notifications/realtime-provider'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { useSelectedFamily } from '@/hooks/use-primary-family'; import { useChatDragSelection } from '@/hooks/use-chat-drag-selection'; import { useChatMessageSelection } from '@/hooks/use-chat-message-selection'; import { FamilyGroupSelector } from '@/components/family/family-group-selector'; import { apiFetch, BotChatMessage, BotChatMeta, ChatMessage, ChatRoom, deleteChatMessage, deleteChatRoom, editChatMessage, fetchBotChatMessages, fetchChatMediaUrl, fetchChatMessages, fetchChatRooms, getApiErrorMessage, markChatRoomRead, sendBotMessage, sendChatMessage, setChatMessagePinned, toggleChatMessageReaction, uploadMediaObject, voteChatPoll } from '@/lib/api'; import { getChatListPreviewText, getMessageCopyText, getMessagePreviewText, isChatInteractiveTarget, isSystemMessage } from '@/lib/chat-message-utils'; import { detectChatMessageType, parseMessageMetadata, resolveChatContentType } from '@/lib/chat-media'; import { uploadChatMediaBatch } from '@/lib/chat-media-upload'; import { fetchMediaBlobWithRetry } from '@/lib/resilient-media-fetch'; import { getSingleEmojiNative, isSingleEmojiMessage } from '@/lib/chat-emoji-display'; import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils'; import { encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat'; import { findMemberRoom, findOrCreateMemberChat, roomDisplayLabel } from '@/lib/family-chat'; import { getE2EMessageText, useE2EChat } from '@/hooks/use-e2e-chat'; import { useChatRoomMedia } from '@/hooks/use-chat-room-media'; import { collectChatImageMessages } from '@/lib/chat-media-album'; import { cn } from '@/lib/utils'; interface PresignedUploadResponse { uploadUrl: string; storageKey: string; expiresAt?: string; uploadToken?: string; } function formatMessageTime(value: string) { return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value)); } function MiniChatEncryptedImageMessage({ message, token, onOpen }: { message: ChatMessage; token: string; onOpen?: () => void }) { const [src, setSrc] = useState(null); const [failed, setFailed] = useState(false); const metadata = parseMessageMetadata(message.metadataJson) as { fileName?: string; width?: number; height?: number }; useEffect(() => { if (!message.storageKey) return; let cancelled = false; let objectUrl: string | null = null; setSrc(null); setFailed(false); async function loadImage() { try { const { accessUrl } = await fetchChatMediaUrl(message.roomId, message.storageKey!, token, metadata.fileName); const blob = await fetchMediaBlobWithRetry(accessUrl, token, { mimeType: message.mimeType, label: 'фото чата' }); if (cancelled) return; objectUrl = URL.createObjectURL(blob); setSrc(objectUrl); } catch { if (!cancelled) setFailed(true); } } void loadImage(); return () => { cancelled = true; if (objectUrl) URL.revokeObjectURL(objectUrl); }; }, [message.id, message.mimeType, message.roomId, message.storageKey, metadata.fileName, token]); const ratio = metadata.width && metadata.height ? Math.max(0.65, Math.min(1.35, metadata.height / metadata.width)) : 0.72; if (failed) { return (
Фото недоступно
); } if (!src) { return (
); } return ( ); } export function MiniFamilyChat() { const { user, token, isPinLocked, isContentReady } = useAuth(); const { showToast } = useToast(); const { subscribe } = useRealtime(); const { group, groupSummaries, hasFamily, selectedGroupId, setSelectedGroupId, presenceByUserId } = useSelectedFamily(Boolean(user && token && !isPinLocked && isContentReady)); const { miniChatOpen, openMiniChat, closeMiniChat, pendingRoomId, pendingMember, clearPending } = useFamilyOverlay(); const [view, setView] = useState<'picker' | 'chat' | 'bot'>('picker'); const [rooms, setRooms] = useState([]); const [activeRoom, setActiveRoom] = useState(null); const [activeBot, setActiveBot] = useState<{ botRef: string; name: string } | null>(null); const [messages, setMessages] = useState([]); const [botMessages, setBotMessages] = useState([]); const [draft, setDraft] = useState(''); const [loadingRooms, setLoadingRooms] = useState(false); const [loadingMessages, setLoadingMessages] = useState(false); const [sending, setSending] = useState(false); const [openingMemberId, setOpeningMemberId] = useState(null); const [deletingRoomId, setDeletingRoomId] = useState(null); const [miniAppUrl, setMiniAppUrl] = useState(null); const [miniAppTitle, setMiniAppTitle] = useState('Mini App'); const [botChatMeta, setBotChatMeta] = useState(null); const [editingMessageId, setEditingMessageId] = useState(null); const [deleteMessagesDialogOpen, setDeleteMessagesDialogOpen] = useState(false); const [pendingDeleteMessageIds, setPendingDeleteMessageIds] = useState([]); const [deletingMessages, setDeletingMessages] = useState(false); const [emojiOpen, setEmojiOpen] = useState(false); const [pollOpen, setPollOpen] = useState(false); const [pollQuestion, setPollQuestion] = useState(''); const [pollOptions, setPollOptions] = useState(['', '']); const [chatToolsOpen, setChatToolsOpen] = useState(false); const [chatToolsTab, setChatToolsTab] = useState('search'); const [mediaDropVisible, setMediaDropVisible] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(null); const mediaComposer = useChatMediaComposer(); const messagesEndRef = useRef(null); const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 }); const fileInputRef = useRef(null); const dragDepthRef = useRef(0); const previousGroupIdRef = useRef(null); const visibleMessages = useMemo( () => messages.filter((message) => !message.isDeleted), [messages] ); const chatImageMessages = useMemo(() => collectChatImageMessages(visibleMessages), [visibleMessages]); const { mediaUrls } = useChatRoomMedia(token, activeRoom?.id ?? null, visibleMessages); function openImageLightbox(message: ChatMessage) { const index = chatImageMessages.findIndex((item) => item.id === message.id); if (index >= 0) setLightboxIndex(index); } const pinnedMessage = useMemo( () => visibleMessages .filter((message) => message.isPinned) .sort((left, right) => new Date(right.pinnedAt ?? right.createdAt).getTime() - new Date(left.pinnedAt ?? left.createdAt).getTime())[0] ?? null, [visibleMessages] ); const { selectionMode, selectedMessageIds, enterSelectionMode, exitSelectionMode, toggleSelectedMessage, addSelectedMessage, handleSelectionPointer } = useChatMessageSelection(visibleMessages); const { handleMessagePointerDown, handleDragEnterMessage, isDragging } = useChatDragSelection({ selectionMode, enterSelectionMode, addSelectedMessage }); const { peerE2eKey, peerKeyLoading, e2ePayloads, e2eErrors, encryptOutgoing, isE2E: activeRoomIsE2E } = useE2EChat({ room: activeRoom, messages, userId: user?.id, token }); const pinnedVisibleText = pinnedMessage ? getE2EMessageText({ message: pinnedMessage, isE2E: activeRoomIsE2E, peerE2eKey, peerKeyLoading, e2ePayload: pinnedMessage.isEncrypted ? e2ePayloads[pinnedMessage.id] : null, e2eError: e2eErrors[pinnedMessage.id] }) : ''; const pinnedPreview = pinnedMessage ? getMessagePreviewText(pinnedMessage, pinnedVisibleText) : ''; const loadRooms = useCallback(async () => { if (!group || !token) return; setLoadingRooms(true); try { const response = await fetchChatRooms(group.id, token); setRooms(response.rooms ?? []); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось загрузить чаты') ?? 'Ошибка'); } finally { setLoadingRooms(false); } }, [group, showToast, token]); const openBotChat = useCallback( async (botRef: string, botName: string) => { if (!token) return; setActiveBot({ botRef, name: botName }); setActiveRoom(null); setView('bot'); setLoadingMessages(true); try { const response = await fetchBotChatMessages(botRef, token); setBotMessages(response.messages ?? []); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось загрузить чат с ботом') ?? 'Ошибка'); } finally { setLoadingMessages(false); } }, [showToast, token] ); const openRoom = useCallback( async (room: ChatRoom) => { if (room.type === 'BOT' && room.botUsername && user) { await openBotChat(room.botUsername, roomDisplayLabel(room, user.id)); return; } setActiveRoom(room); setActiveBot(null); setView('chat'); setLoadingMessages(true); try { const response = await fetchChatMessages(room.id, token); setMessages((response.messages ?? []).filter((message) => !message.isDeleted)); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось загрузить сообщения') ?? 'Ошибка'); } finally { setLoadingMessages(false); } }, [openBotChat, showToast, token, user] ); const openMemberChat = useCallback( async (memberUserId: string, memberName: string, options?: { isBot?: boolean; botUsername?: string }) => { if (!group || !token || !user) return; if (options?.isBot && options.botUsername) { await openBotChat(options.botUsername, memberName); return; } const existing = findMemberRoom(rooms, memberUserId, user.id, options); if (existing) { await openRoom(existing); return; } setOpeningMemberId(memberUserId); try { const room = await findOrCreateMemberChat(group.id, memberUserId, memberName, user.id, token); await loadRooms(); await openRoom(room); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось открыть чат') ?? 'Ошибка'); } finally { setOpeningMemberId(null); } }, [group, loadRooms, openBotChat, openRoom, rooms, showToast, token, user] ); useEffect(() => { if (!miniChatOpen || !hasFamily) return; void loadRooms(); }, [group?.id, hasFamily, loadRooms, miniChatOpen]); useEffect(() => { if (!group?.id) return; if (previousGroupIdRef.current && previousGroupIdRef.current !== group.id && miniChatOpen) { setView('picker'); setActiveRoom(null); setActiveBot(null); setMessages([]); setBotMessages([]); setDraft(''); } previousGroupIdRef.current = group.id; }, [group?.id, miniChatOpen]); useEffect(() => { if (!miniChatOpen) { setView('picker'); setActiveRoom(null); setActiveBot(null); setMessages([]); setBotMessages([]); setDraft(''); setLightboxIndex(null); exitSelectionMode(); setEmojiOpen(false); setPollOpen(false); clearPending(); return; } if (pendingRoomId && rooms.length) { const room = rooms.find((item) => item.id === pendingRoomId); if (room) { void openRoom(room); clearPending(); } } else if (pendingMember && user) { void openMemberChat(pendingMember.userId, pendingMember.name, { isBot: pendingMember.isBot, botUsername: pendingMember.botUsername }); clearPending(); } }, [clearPending, exitSelectionMode, miniChatOpen, openMemberChat, openRoom, pendingMember, pendingRoomId, rooms, user]); useEffect(() => { exitSelectionMode(); setEditingMessageId(null); setDraft(''); setEmojiOpen(false); }, [activeRoom?.id, exitSelectionMode]); useEffect(() => { if (!activeBot) return; return subscribe((event) => { if (event.type !== 'bot_message' && event.type !== 'bot_message_edited') return; const payload = event.payload as { text?: string | null; messageId?: number; messageType?: string; editedAt?: string; }; if (!payload?.messageId) return; setBotMessages((current) => { if (event.type === 'bot_message_edited') { return current.map((item) => item.messageId === payload.messageId ? { ...item, text: payload.text ?? item.text, createdAt: payload.editedAt ?? item.createdAt } : item ); } const exists = current.some((item) => item.messageId === payload.messageId && item.direction === 'out'); if (exists) return current; return [ ...current, { id: `out-live-${payload.messageId}`, direction: 'out' as const, text: payload.text ?? '', messageType: payload.messageType ?? 'text', messageId: payload.messageId ?? 0, createdAt: new Date().toISOString() } ]; }); }); }, [activeBot, subscribe]); useEffect(() => { if (!activeRoom) return; return subscribe((event) => { const payload = event.payload; if (payload?.roomId && payload.roomId !== activeRoom.id) return; if (event.type === 'chat_message' && payload?.message) { const message = payload.message as ChatMessage; if (message.isDeleted) return; setMessages((current) => { if (current.some((item) => item.id === message.id)) return current; return [...current, message]; }); } if (event.type === 'chat_message_updated') { const updated = payload?.message as ChatMessage | undefined; if (updated) { if (updated.isDeleted) { setMessages((current) => current.filter((item) => item.id !== updated.id)); } else { setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); } } } if (event.type === 'chat_message_deleted') { const deleted = payload?.message as ChatMessage | undefined; if (deleted?.id) { setMessages((current) => current.filter((item) => item.id !== deleted.id)); } } }); }, [activeRoom, subscribe]); useEffect(() => { autoScrollStateRef.current = { id: null, count: 0 }; }, [activeRoom?.id]); useEffect(() => { const lastMessage = visibleMessages[visibleMessages.length - 1]; const previous = autoScrollStateRef.current; const next = { id: lastMessage?.id ?? null, count: visibleMessages.length }; autoScrollStateRef.current = next; if (!next.id) return; const lastMessageChanged = previous.id !== next.id; const messageWasAppended = next.count >= previous.count; if (lastMessageChanged && messageWasAppended) { messagesEndRef.current?.scrollIntoView({ behavior: previous.id ? 'smooth' : 'auto' }); } }, [visibleMessages]); useEffect(() => { if (!activeRoom || !token || !messages.length) return; const last = messages[messages.length - 1]; const timer = window.setTimeout(() => { void markChatRoomRead(activeRoom.id, last.id, token).catch(() => {}); }, 400); return () => window.clearTimeout(timer); }, [activeRoom, messages, token]); function cancelEditMessage() { setEditingMessageId(null); setDraft(''); } function requestDeleteMessages(messageIds: string[]) { if (!messageIds.length) return; setPendingDeleteMessageIds(messageIds); setDeleteMessagesDialogOpen(true); } async function confirmDeleteMessages() { if (!token || !pendingDeleteMessageIds.length) return; const ids = [...pendingDeleteMessageIds]; setDeletingMessages(true); setMessages((current) => current.filter((item) => !ids.includes(item.id))); try { for (const messageId of ids) { await deleteChatMessage(messageId, token); if (editingMessageId === messageId) cancelEditMessage(); } setDeleteMessagesDialogOpen(false); setPendingDeleteMessageIds([]); exitSelectionMode(); showToast(ids.length === 1 ? 'Сообщение удалено' : 'Сообщения удалены'); } catch (error) { if (view === 'bot' && activeBot) { const response = await fetchBotChatMessages(activeBot.botRef, token); setBotMessages(response.messages ?? []); } else if (activeRoom && activeRoom.type !== 'BOT') { const response = await fetchChatMessages(activeRoom.id, token); setMessages((response.messages ?? []).filter((message) => !message.isDeleted)); } showToast(getApiErrorMessage(error, 'Не удалось удалить сообщение') ?? 'Ошибка'); } finally { setDeletingMessages(false); } } async function handleSend() { if (!token || !draft.trim()) return; if (view === 'bot' && activeBot) { setSending(true); try { const text = draft.trim(); setBotMessages((current) => [ ...current, { id: `in-local-${Date.now()}`, direction: 'in', text, messageType: 'text', messageId: 0, createdAt: new Date().toISOString() } ]); setDraft(''); await sendBotMessage(activeBot.botRef, text, token); const response = await fetchBotChatMessages(activeBot.botRef, token); setBotMessages(response.messages ?? []); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось отправить сообщение') ?? 'Ошибка'); } finally { setSending(false); } return; } if (!activeRoom) return; if (editingMessageId) { setSending(true); try { const updated = await editChatMessage(editingMessageId, draft.trim(), token); setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); cancelEditMessage(); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось изменить сообщение') ?? 'Ошибка'); } finally { setSending(false); } return; } setSending(true); try { let content = draft.trim(); let isEncrypted = false; if (activeRoomIsE2E) { content = await encryptOutgoing({ kind: 'text', text: content }); isEncrypted = true; } const message = await sendChatMessage(activeRoom.id, { type: 'TEXT', content, isEncrypted }, token); setMessages((current) => [...current, message]); setDraft(''); void loadRooms(); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось отправить сообщение') ?? 'Ошибка'); } finally { setSending(false); } } function insertEmojiToDraft(nativeEmoji: string) { setDraft((current) => `${current}${nativeEmoji}`); } function getSelectedMessages() { const selected = new Set(selectedMessageIds); return visibleMessages.filter((message) => selected.has(message.id)); } async function handleBulkCopy() { const selected = getSelectedMessages(); if (!selected.length) return; const lines = selected .map((message) => getMessageCopyText( message, getE2EMessageText({ message, isE2E: activeRoomIsE2E, peerE2eKey, peerKeyLoading, e2ePayload: message.isEncrypted ? e2ePayloads[message.id] : null, e2eError: e2eErrors[message.id] }) ) ) .filter(Boolean); if (!lines.length) { showToast('Нечего копировать'); return; } await navigator.clipboard.writeText(lines.join('\n\n')); showToast('Сообщения скопированы'); exitSelectionMode(); } async function handleBulkDelete() { const selected = getSelectedMessages().filter((message) => message.senderId === user?.id); if (!selected.length) { showToast('Можно удалять только свои сообщения'); return; } requestDeleteMessages(selected.map((message) => message.id)); } async function handleTogglePin(message: ChatMessage) { if (!token) return; try { const updated = await setChatMessagePinned(message.id, !message.isPinned, token); setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); showToast(updated.isPinned ? 'Сообщение закреплено' : 'Сообщение откреплено'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось изменить закрепление') ?? 'Ошибка'); } } function handleSelectionReply() { const selected = getSelectedMessages(); if (selected.length !== 1) { showToast('Ответить можно только на одно сообщение'); return; } showToast('Ответ доступен в полном виде семьи'); } function handleSelectionPin() { const selected = getSelectedMessages(); if (selected.length !== 1) { showToast('Закрепить можно только одно сообщение'); return; } void handleTogglePin(selected[0]!); } async function uploadChatFile(file: File) { 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 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, 'Не удалось отправить файлы') ?? 'Ошибка'); } 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); if (!pollQuestion.trim() || options.length < 2) { showToast('Укажите вопрос и минимум 2 варианта'); return; } setSending(true); try { const message = await sendChatMessage( activeRoom.id, { type: 'POLL', poll: { question: pollQuestion.trim(), options } }, token ); setMessages((current) => [...current, message]); setPollOpen(false); setPollQuestion(''); setPollOptions(['', '']); void loadRooms(); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось создать опрос') ?? 'Ошибка'); } finally { setSending(false); } } async function handleDeleteRoom(roomId: string) { if (!token) return; const room = rooms.find((item) => item.id === roomId); if (!room || room.type === 'GENERAL') return; if (!window.confirm('Удалить этот чат? Все сообщения будут удалены без возможности восстановления.')) return; setDeletingRoomId(roomId); try { await deleteChatRoom(roomId, token); if (activeRoom?.id === roomId) { setActiveRoom(null); setMessages([]); setView('picker'); } await loadRooms(); showToast('Чат удалён'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось удалить чат') ?? 'Ошибка'); } finally { setDeletingRoomId(null); } } const otherMembers = useMemo( () => (group?.members ?? []).filter((member) => member.userId !== user?.id), [group?.members, user?.id] ); const pathname = usePathname(); const onFamilyChatPage = Boolean(pathname && /^\/family\/[^/]+$/.test(pathname)); if (!user || !token || isPinLocked) return null; if (onFamilyChatPage) return null; return ( <> {miniChatOpen ? (
{view === 'chat' || view === 'bot' ? ( ) : null}

{view === 'bot' && activeBot ? activeBot.name : view === 'chat' && activeRoom && user ? roomDisplayLabel(activeRoom, user.id) : 'Чат семьи'}

{view === 'chat' && activeRoom && user ? (
{activeRoom.members.length} участников
) : null} {hasFamily ? ( ) : (

Семья не создана

)}
{view === 'bot' && botChatMeta?.manageWebAppUrl && user?.id === botChatMeta.botOwnerId ? ( ) : null} {view === 'chat' && activeRoom ? ( <> ) : null} {view === 'chat' && activeRoom && activeRoom.type !== 'GENERAL' ? ( ) : null}
{!hasFamily ? (

Создайте семейную группу, чтобы общаться с близкими из любого раздела.

Перейти к созданию семьи
) : view === 'picker' ? (

Чаты

{loadingRooms ? (
Загрузка...
) : rooms.length ? (
{rooms.map((room) => (
{room.type !== 'GENERAL' ? ( ) : null}
))}
) : (

Чаты пока не созданы

)}

Написать участнику

{otherMembers.map((member) => { const presence = presenceByUserId.get(member.userId); return ( ); })} {!otherMembers.length ? (

Пригласите участников в семью

) : null}
Открыть семью полностью
) : view === 'bot' && activeBot ? ( { setMiniAppTitle('Mini App'); setMiniAppUrl(url); }} /> ) : (
{ 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 }); }} > { dragDepthRef.current = 0; setMediaDropVisible(false); queueMediaFiles(files, { sendAsFile: true }); }} onDropCompressed={(files) => { dragDepthRef.current = 0; setMediaDropVisible(false); queueMediaFiles(files, { sendAsFile: false }); }} /> {pinnedMessage ? ( { if (!scrollToChatMessage(pinnedMessage.id)) { showToast('Закреплённое сообщение пока не загружено в ленте'); } }} /> ) : null}
{loadingMessages ? (
Загрузка...
) : visibleMessages.length ? ( visibleMessages.map((message, messageIndex) => { const previousMessage = visibleMessages[messageIndex - 1]; const showDateSeparator = shouldShowChatDateSeparator(message.createdAt, previousMessage?.createdAt); if (isSystemMessage(message)) { return (
{showDateSeparator ? : null}

{getMessagePreviewText(message)}

); } const mine = message.senderId === user.id; const e2ePayload = message.isEncrypted ? e2ePayloads[message.id] : null; const visibleText = getE2EMessageText({ message, isE2E: activeRoomIsE2E, peerE2eKey, peerKeyLoading, e2ePayload, e2eError: e2eErrors[message.id] }); const emojiContent = message.type === 'EMOJI' || e2ePayload?.kind === 'emoji' ? e2ePayload?.emojiId ?? message.content : null; const isSelected = selectedMessageIds.includes(message.id); const isLargeEmoji = isSingleEmojiMessage({ type: message.type, content: emojiContent ?? message.content, visibleText }); const largeEmojiNative = isLargeEmoji ? getSingleEmojiNative({ type: message.type, content: emojiContent ?? message.content, visibleText }) : ''; const bubble = (
{!mine && !isLargeEmoji ? (

{message.senderName}

) : null} {message.type === 'POLL' && message.poll ? (

{message.poll.question}

{message.poll.options.map((option) => { const total = message.poll!.options.reduce((sum, item) => sum + item.voteCount, 0) || 1; const percent = Math.round((option.voteCount / total) * 100); return ( ); })}
) : message.storageKey && message.type === 'IMAGE' && message.isEncrypted && token ? (
openImageLightbox(message)} /> {visibleText?.trim() ? (
) : null}
) : message.storageKey && message.type === 'IMAGE' && token ? (
{(() => { const media = message.storageKey ? mediaUrls[message.storageKey] : undefined; const meta = parseMessageMetadata(message.metadataJson) as { fileName?: string; width?: number; height?: number; }; if (!media?.blobUrl || media.status === 'loading') { const ratio = meta.width && meta.height ? Math.max(0.65, Math.min(1.35, meta.height / meta.width)) : 0.72; return (
); } return ( ); })()} {visibleText?.trim() ? (
) : null}
) : message.storageKey ? (

{getMessagePreviewText(message, visibleText)}

) : isLargeEmoji ? (
{largeEmojiNative}
{formatMessageTime(message.createdAt)}
) : emojiContent ? (
) : (
)} {!isLargeEmoji ? (

{formatMessageTime(message.createdAt)}

) : null} {!isLargeEmoji ? ( { if (!token) return; void toggleChatMessageReaction(message.id, emoji, token).then((updated) => { setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); }); }} compact /> ) : null}
); return (
{showDateSeparator ? : null}
{ if (isChatInteractiveTarget(event.target)) return; handleMessagePointerDown(message.id, event); }} onMouseEnter={() => handleDragEnterMessage(message.id)} onClick={(event) => { if (selectionMode) return; if (event.ctrlKey || event.metaKey || event.shiftKey) { handleSelectionPointer(message.id, event); } }} > {!mine ? ( ) : null} {selectionMode ? ( void handleBulkCopy()} onForward={() => showToast('Пересылка доступна в полном виде семьи')} onDelete={() => void handleBulkDelete()} onTogglePin={handleSelectionPin} onCancel={exitSelectionMode} > toggleSelectedMessage(message.id)} > {bubble} ) : ( { setEditingMessageId(message.id); setDraft(message.content ?? ''); }} onDelete={() => requestDeleteMessages([message.id])} onForward={() => showToast('Пересылка доступна в полном виде семьи')} onTogglePin={() => void handleTogglePin(message)} onSelect={() => enterSelectionMode(message.id)} onToggleReaction={async (emoji) => { if (!token) return; const updated = await toggleChatMessageReaction(message.id, emoji, token); setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); }} onCopy={() => showToast('Текст скопирован')} > {bubble} )} {mine ? ( ) : null}
); }) ) : (

Сообщений пока нет

)}
{selectionMode ? ( void handleBulkCopy()} onForward={() => showToast('Пересылка доступна в полном виде семьи')} onDelete={() => void handleBulkDelete()} /> ) : (
{editingMessageId ? ( item.id === editingMessageId)?.content || undefined} onCancel={cancelEditMessage} /> ) : null} {emojiOpen ? (
insertEmojiToDraft(nativeEmoji)} />
) : null}
{!activeRoomIsE2E ? ( ) : null} setDraft(event.target.value)} placeholder={editingMessageId ? 'Измените сообщение' : 'Сообщение'} className="min-h-[40px] flex-1 rounded-xl" onKeyDown={(event) => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void handleSend(); } if (event.key === 'Escape') { if (editingMessageId) { cancelEditMessage(); } } }} />
)}
)}
) : null} getMessagePreviewText( message, getE2EMessageText({ message, isE2E: activeRoomIsE2E, peerE2eKey, peerKeyLoading, e2ePayload: message.isEncrypted ? e2ePayloads[message.id] : null, e2eError: e2eErrors[message.id] }) ) } /> { setDeleteMessagesDialogOpen(open); if (!open) { setPendingDeleteMessageIds([]); exitSelectionMode(); } }} onConfirm={() => void confirmDeleteMessages()} /> Создать опрос setPollQuestion(event.target.value)} />
{pollOptions.map((option, index) => ( setPollOptions((current) => current.map((item, i) => (i === index ? event.target.value : item))) } /> ))}
{ const files = Array.from(event.target.files ?? []); if (files.length) queueMediaFiles(files); event.target.value = ''; }} /> void mediaComposer.addFiles(files)} onClose={mediaComposer.close} onSend={() => void submitMediaCompose()} /> setMiniAppUrl(null)} /> setLightboxIndex(null)} onIndexChange={setLightboxIndex} /> ); }