'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { ArrowLeft, ArrowRightLeft, BarChart3, CalendarDays, Camera, Check, CheckCheck, Circle, FileText, ImageIcon, Loader2, LogOut, MapPin, Mic, Paperclip, Pin, Plus, Search, Send, Settings, Smile, Trash2, UserPlus, Users, Video, Volume2, VolumeX, X } from 'lucide-react'; import { AvatarWithPresence } from '@/components/family/avatar-with-presence'; import { FamilyInviteDialog } from '@/components/family/family-invite-dialog'; import { HoverUploadAvatar } from '@/components/family/hover-upload-avatar'; import { BotMiniAppSheet, FamilyBotChat } from '@/components/family/family-bot-chat'; 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'; import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner'; import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu'; import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner'; import { ChatMessageReactions } from '@/components/chat/chat-message-reactions'; import { ChatSelectionToolbar } from '@/components/chat/chat-selection-toolbar'; import { EmojiPicker } from '@/components/chat/emoji-picker'; import { InlineEditableTitle } from '@/components/chat/inline-editable-title'; import { RepliedMessagePreview, scrollToChatMessage } from '@/components/chat/replied-message-preview'; import { TypingIndicator } from '@/components/chat/typing-indicator'; import { VoiceMessagePlayer } from '@/components/chat/voice-message-player'; import { ChatLocationCard } from '@/components/chat/chat-location-card'; import { ChatLocationShareDialog, type ChatLocationSharePayload } from '@/components/chat/chat-location-share-dialog'; import { ChatVideoCaptureDialog, type ChatVideoCaptureMode } from '@/components/chat/chat-video-capture-dialog'; import { ChatVideoNotePlayer } from '@/components/chat/chat-video-note-player'; import { ChatVideoPlayer } from '@/components/chat/chat-video-player'; import { NotificationBell } from '@/components/notifications/notification-bell'; import { UserMenu } from '@/components/id/user-menu'; import { UserAvatar } from '@/components/id/user-avatar'; import { useAuth } from '@/components/id/auth-provider'; import { useToast } from '@/components/id/toast-provider'; import { useRealtime } from '@/components/notifications/realtime-provider'; import { Button } from '@/components/ui/button'; import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { useRequireAuth } from '@/hooks/use-require-auth'; import { addChatRoomMember, apiFetch, ChatMessage, ChatRoom, createChatRoom, createE2ERoom, deleteChatMessage, deleteChatRoom, deleteFamilyGroup, editChatMessage, BotChatMeta, FamilyGroup, FamilyPresenceMember, fetchChatMessages, fetchChatRooms, fetchFamilyGroup, fetchFamilyPresence, forwardChatMessages, getApiErrorMessage, getAccessToken, leaveFamilyGroup, markChatRoomRead, removeChatRoomMember, removeFamilyMember, reportChatTyping, sendChatMessage, setChatMessagePinned, setChatRoomPinned, setChatRoomMuted, toggleChatMessageReaction, transferFamilyOwnership, updateFamilyGroup, uploadMediaObject, voteChatPoll } from '@/lib/api'; import { decryptE2EBlob, decryptE2EPayload, encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat'; import { getE2EMessageText, useE2EChat } from '@/hooks/use-e2e-chat'; import { useChatMessageSelection } from '@/hooks/use-chat-message-selection'; import { useChatDragSelection } from '@/hooks/use-chat-drag-selection'; import { useChatTyping } from '@/hooks/use-chat-typing'; import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils'; import { AVATAR_UPDATED_EVENT, dispatchChatRoomAvatarUpdated } from '@/lib/avatar-events'; import { getSingleEmojiNative, isSingleEmojiMessage } from '@/lib/chat-emoji-display'; import { findMemberRoom, readStoredActiveRoomId, roomDisplayLabel, storeActiveRoomId } from '@/lib/family-chat'; import { getChatListPreviewText, getMessageCopyText, getMessagePreviewText, isChatInteractiveTarget, isSystemMessage, parseForwardedFrom } from '@/lib/chat-message-utils'; import { cn } from '@/lib/utils'; import { createBlobObjectUrl, fetchAuthenticatedMediaBlob, mediaExpiresAtMs, revokeBlobObjectUrl, shouldRefreshMedia, triggerBlobDownload } from '@/lib/authenticated-media'; import { detectChatMessageType, fileIconLabel, formatFileSize, parseLocationMetadata, 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; storageKey: string; expiresAt?: string; uploadToken?: string; } interface LoadedChatMedia { blobUrl?: string; expiresAt?: number; fileName?: string; mimeType?: string; status: 'loading' | 'ready' | 'error'; errorMessage?: string; } function formatMessageTime(value: string) { return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value)); } function formatLastSeen(value?: string) { if (!value) return 'Не в сети'; const date = new Date(value); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMinutes = Math.floor(diffMs / 60_000); if (diffMinutes < 1) return 'был(а) только что'; if (diffMinutes < 60) return `был(а) ${diffMinutes} мин. назад`; const diffHours = Math.floor(diffMinutes / 60); if (diffHours < 24) return `был(а) ${diffHours} ч. назад`; return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(date); } function formatPresenceStatus(member: FamilyPresenceMember) { if (member.online) return 'В сети'; return formatLastSeen(member.lastSeenAt); } function FamilySidebarAvatar({ groupId, name, hasAvatar, token, uploading, onUpload }: { groupId: string; name: string; hasAvatar?: boolean; token: string | null; uploading?: boolean; onUpload: (file: File) => void; }) { const { isLoading: authLoading } = useAuth(); const [url, setUrl] = useState(null); useEffect(() => { if (!hasAvatar || !token || authLoading) { setUrl(null); return; } apiFetch<{ accessUrl: string }>(`/media/families/${groupId}/avatar/url`, {}, token) .then((response) => setUrl(response.accessUrl)) .catch(() => setUrl(null)); }, [authLoading, groupId, hasAvatar, token]); return ( ); } function memberRoleLabel(member: { familyRole?: string; isChatCreator?: boolean }) { if (member.familyRole === 'owner') return 'Создатель семьи'; if (member.isChatCreator) return 'Создатель чата'; return 'Участник'; } 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, isLoading: authLoading } = useAuth(); const { isReady, isPinLocked } = useRequireAuth(); const { showToast } = useToast(); const { subscribe, connected } = useRealtime(); const [group, setGroup] = useState(null); const [rooms, setRooms] = useState([]); const [activeRoomId, setActiveRoomId] = useState(() => readStoredActiveRoomId(groupId)); const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(''); const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); const [inviteOpen, setInviteOpen] = useState(false); const [createRoomOpen, setCreateRoomOpen] = useState(false); const [pollOpen, setPollOpen] = useState(false); const [membersOpen, setMembersOpen] = useState(false); const [familyMembersOpen, setFamilyMembersOpen] = useState(false); const [addMembersOpen, setAddMembersOpen] = useState(false); const [newRoomName, setNewRoomName] = useState(''); const [selectedMembers, setSelectedMembers] = useState([]); const [pollQuestion, setPollQuestion] = useState(''); const [pollOptions, setPollOptions] = useState(['', '']); const [emojiOpen, setEmojiOpen] = useState(false); const [recording, setRecording] = useState(false); const [mediaUrls, setMediaUrls] = useState>({}); const [downloadingKey, setDownloadingKey] = useState(null); const [editingMessageId, setEditingMessageId] = useState(null); const [presenceMembers, setPresenceMembers] = useState([]); const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false); const [leaveFamilyDialogOpen, setLeaveFamilyDialogOpen] = useState(false); const [transferOwnershipDialogOpen, setTransferOwnershipDialogOpen] = useState(false); const [leavingFamily, setLeavingFamily] = useState(false); const [transferringOwnership, setTransferringOwnership] = useState(false); const [selectedNewOwnerUserId, setSelectedNewOwnerUserId] = useState(null); const [deletingFamily, setDeletingFamily] = useState(false); const [deleteMessagesDialogOpen, setDeleteMessagesDialogOpen] = useState(false); const [pendingDeleteMessageIds, setPendingDeleteMessageIds] = useState([]); const [miniAppUrl, setMiniAppUrl] = useState(null); const [miniAppTitle, setMiniAppTitle] = useState('Mini App'); const [botChatMeta, setBotChatMeta] = useState(null); const [deletingRoomId, setDeletingRoomId] = useState(null); const [pinningRoomId, setPinningRoomId] = useState(null); const [familyAvatarUploading, setFamilyAvatarUploading] = useState(false); const [roomAvatarUploading, setRoomAvatarUploading] = useState(false); const [forwardDialogOpen, setForwardDialogOpen] = useState(false); const [forwarding, setForwarding] = useState(false); const [bulkDeleting, setBulkDeleting] = useState(false); const [replyToMessage, setReplyToMessage] = useState(null); const [chatToolsOpen, setChatToolsOpen] = useState(false); const [chatToolsTab, setChatToolsTab] = useState('search'); const [mediaDropVisible, setMediaDropVisible] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(null); const [videoCaptureOpen, setVideoCaptureOpen] = useState(false); const [videoCaptureMode, setVideoCaptureMode] = useState('note'); const [locationShareOpen, setLocationShareOpen] = useState(false); const mediaComposer = useChatMediaComposer(); const messagesEndRef = useRef(null); const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 }); const readReceiptTimerRef = useRef | null>(null); const typingEmitTimerRef = useRef | null>(null); const lastTypingSentAtRef = useRef(0); const mediaRecorderRef = useRef(null); const recordingStartedAtRef = useRef(null); const mediaUrlsRef = useRef>({}); const mediaLoadInFlightRef = useRef>(new Set()); const fileInputRef = useRef(null); const videoInputRef = useRef(null); const dragDepthRef = useRef(0); useEffect(() => { mediaUrlsRef.current = mediaUrls; }, [mediaUrls]); const appendMessage = useCallback((message: ChatMessage) => { if (message.isDeleted) return; setMessages((current) => (current.some((item) => item.id === message.id) ? current : [...current, message])); }, []); 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] ); 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 { typers, registerTyper, clearTypers } = useChatTyping(user?.id); const { selectionMode, selectedMessageIds, setSelectedMessageIds, enterSelectionMode, exitSelectionMode, toggleSelectedMessage, addSelectedMessage, handleSelectionPointer } = useChatMessageSelection(visibleMessages); const { handleMessagePointerDown, handleDragEnterMessage, isDragging } = useChatDragSelection({ selectionMode, enterSelectionMode, addSelectedMessage }); const activeRoom = useMemo(() => rooms.find((room) => room.id === activeRoomId) ?? null, [activeRoomId, rooms]); const activeRoomIsBot = activeRoom?.type === 'BOT'; useEffect(() => { if (!activeRoomIsBot) { setBotChatMeta(null); } }, [activeRoomIsBot, activeRoom?.id]); const activeRoomIsE2E = activeRoom?.type === 'E2E'; const { peerE2eKey, peerKeyLoading, e2ePayloads, e2eErrors, encryptOutgoing } = 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 activeRoomTitle = useMemo( () => (activeRoom && user ? roomDisplayLabel(activeRoom, user.id) : ''), [activeRoom, user] ); const myMembership = activeRoom?.members.find((member) => member.userId === user?.id); const isFamilyOwner = group?.ownerId === user?.id; const humanMembers = useMemo( () => (group?.members ?? []).filter((member) => !member.isBot && member.role !== 'bot'), [group?.members] ); const otherHumanMembers = useMemo( () => humanMembers.filter((member) => member.userId !== user?.id), [humanMembers, user?.id] ); const ownerMustTransfer = Boolean(isFamilyOwner && otherHumanMembers.length > 0); const ownerLeaveDeletesFamily = Boolean(isFamilyOwner && otherHumanMembers.length === 0); const mobileChatOpen = Boolean(activeRoomId); const isChatCreator = activeRoom?.createdById === user?.id; const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator || activeRoomIsBot); const canManageActiveBot = Boolean( activeRoomIsBot && botChatMeta?.manageWebAppUrl && user?.id && botChatMeta.botOwnerId === user.id ); const familyMembersNotInRoom = useMemo(() => { if (!group?.members || !activeRoom) return []; const inRoom = new Set(activeRoom.members.map((member) => member.userId)); return group.members.filter((member) => !inRoom.has(member.userId)); }, [activeRoom, group?.members]); const presenceByUserId = useMemo( () => new Map(presenceMembers.map((member) => [member.userId, member])), [presenceMembers] ); const onlineCount = useMemo(() => presenceMembers.filter((member) => member.online).length, [presenceMembers]); const loadGroup = useCallback(async () => { if (!token || isPinLocked) return; const [groupResponse, roomsResponse] = await Promise.all([fetchFamilyGroup(groupId, token), fetchChatRooms(groupId, token)]); setGroup(groupResponse); const roomList = roomsResponse.rooms ?? []; setRooms(roomList); setActiveRoomId((current) => { const preferred = current ?? readStoredActiveRoomId(groupId); if (preferred && roomList.some((room) => room.id === preferred)) { return preferred; } return roomList[0]?.id ?? null; }); }, [groupId, isPinLocked, token]); useEffect(() => { storeActiveRoomId(groupId, activeRoomId); }, [activeRoomId, groupId]); useEffect(() => { exitSelectionMode(); setReplyToMessage(null); cancelEditMessage(); clearTypers(); }, [activeRoomId, clearTypers, exitSelectionMode]); useEffect(() => { setActiveRoomId(readStoredActiveRoomId(groupId)); }, [groupId]); const loadMessages = useCallback(async (roomId: string) => { if (!token || isPinLocked) return; const room = rooms.find((item) => item.id === roomId); if (!room || room.type === 'BOT') return; try { const response = await fetchChatMessages(roomId, token); setMessages((response.messages ?? []).filter((message) => !message.isDeleted)); } catch (error) { const message = getApiErrorMessage(error, 'Не удалось загрузить сообщения'); if (message && !message.includes('Bot API')) { showToast(message); } } }, [isPinLocked, rooms, showToast, token]); const loadPresence = useCallback(async () => { const accessToken = getAccessToken() ?? token?.trim() ?? null; if (!accessToken || isPinLocked) return; try { const response = await fetchFamilyPresence(groupId, accessToken); setPresenceMembers(response.members ?? []); } catch { // ignore background presence errors } }, [groupId, isPinLocked, token]); useEffect(() => { const accessToken = getAccessToken() ?? token?.trim() ?? null; if (!isReady || !accessToken || isPinLocked || authLoading) return; setLoading(true); loadGroup() .catch((error) => showToast(getApiErrorMessage(error, 'Не удалось загрузить семью') ?? 'Ошибка')) .finally(() => setLoading(false)); void loadPresence(); }, [authLoading, isPinLocked, isReady, loadGroup, loadPresence, showToast, token]); useEffect(() => { function handleAvatarUpdated() { void loadGroup(); } window.addEventListener(AVATAR_UPDATED_EVENT, handleAvatarUpdated); return () => window.removeEventListener(AVATAR_UPDATED_EVENT, handleAvatarUpdated); }, [loadGroup]); useEffect(() => { const accessToken = getAccessToken() ?? token?.trim() ?? null; if (!accessToken || isPinLocked || authLoading) return; void loadPresence(); const timer = window.setInterval(() => void loadPresence(), 25_000); return () => window.clearInterval(timer); }, [authLoading, connected, isPinLocked, loadPresence, token]); useEffect(() => { if (!activeRoomId || activeRoomIsBot) return; if (!rooms.some((room) => room.id === activeRoomId)) return; void loadMessages(activeRoomId); setEditingMessageId(null); setDraft(''); }, [activeRoomId, activeRoomIsBot, loadMessages, rooms]); useEffect(() => { if (!activeRoomId || !token || !messages.length) return; const timer = window.setTimeout(() => { const last = messages[messages.length - 1]; void markChatRoomRead(activeRoomId, last.id, token).catch(() => {}); }, 500); return () => window.clearTimeout(timer); }, [activeRoomId, messages, token]); useEffect(() => { autoScrollStateRef.current = { id: null, count: 0 }; }, [activeRoomId]); 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 (!activeRoomId || !token || activeRoomIsBot) return; if (!draft.trim()) return; const emitTyping = () => { lastTypingSentAtRef.current = Date.now(); void reportChatTyping(activeRoomId, token).catch(() => {}); }; const elapsed = Date.now() - lastTypingSentAtRef.current; if (elapsed >= 2000) { emitTyping(); } else if (typingEmitTimerRef.current) { clearTimeout(typingEmitTimerRef.current); typingEmitTimerRef.current = setTimeout(emitTyping, 2000 - elapsed); } else { typingEmitTimerRef.current = setTimeout(emitTyping, 2000 - elapsed); } return () => { if (typingEmitTimerRef.current) { clearTimeout(typingEmitTimerRef.current); typingEmitTimerRef.current = null; } }; }, [activeRoomId, activeRoomIsBot, draft, token]); useEffect(() => { return subscribe((event) => { const payload = event.payload; if (event.type === 'bot_message' || event.type === 'bot_message_edited') { if (activeRoomIsBot) { void loadGroup(); } return; } if (payload?.roomId && payload.roomId !== activeRoomId) { if (event.type === 'chat_message') void loadGroup(); return; } if (event.type === 'chat_typing' && payload?.roomId === activeRoomId) { const typerId = typeof payload.userId === 'string' ? payload.userId : ''; const typerName = typeof payload.userName === 'string' ? payload.userName : 'Участник'; if (typerId) registerTyper(typerId, typerName); return; } if (event.type === 'chat_message') { if (payload?.message) { appendMessage(payload.message as ChatMessage); } else if (activeRoomId && !activeRoomIsBot) { void loadMessages(activeRoomId); } void loadGroup(); return; } 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)); setSelectedMessageIds((current) => current.filter((id) => id !== updated.id)); } else { setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); } } else if (activeRoomId && !activeRoomIsBot) { void loadMessages(activeRoomId); } void loadGroup(); return; } 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)); setSelectedMessageIds((current) => current.filter((id) => id !== deleted.id)); if (editingMessageId === deleted.id) { cancelEditMessage(); } } else if (activeRoomId && !activeRoomIsBot) { void loadMessages(activeRoomId); } void loadGroup(); return; } if (event.type === 'chat_read_receipt' && activeRoomId && !activeRoomIsBot) { if (readReceiptTimerRef.current) clearTimeout(readReceiptTimerRef.current); readReceiptTimerRef.current = setTimeout(() => void loadMessages(activeRoomId), 400); return; } if (event.type === 'family_group_deleted' && payload?.groupId === groupId) { showToast('Семья была удалена'); router.push('/family'); return; } if (event.type === 'family_left_self' && payload?.groupId === groupId) { router.push('/family'); return; } if (event.type === 'family_member_left' && payload?.groupId === groupId) { const userName = typeof payload?.userName === 'string' ? payload.userName : 'Участник'; showToast(`${userName} покинул(а) семью`); void loadGroup(); return; } if (event.type === 'family_member_removed' && payload?.groupId === groupId) { const userName = typeof payload?.userName === 'string' ? payload.userName : 'Участник'; showToast(`${userName} удалён(а) из семьи`); void loadGroup(); return; } if (event.type === 'family_ownership_transferred' && payload?.groupId === groupId) { showToast('Вы стали главой семьи'); void loadGroup(); } }); }, [ activeRoomId, activeRoomIsBot, appendMessage, editingMessageId, groupId, loadGroup, loadMessages, registerTyper, router, setSelectedMessageIds, showToast, subscribe ]); const loadChatMedia = useCallback( async (message: ChatMessage, force = false) => { if (!token || !activeRoomId || !message.storageKey) return; const storageKey = message.storageKey; const existing = mediaUrlsRef.current[storageKey]; if (!force && existing?.status === 'ready' && existing.blobUrl && existing.expiresAt && !shouldRefreshMedia(existing.expiresAt)) { return; } if (mediaLoadInFlightRef.current.has(storageKey) && !force) { return; } if (message.isEncrypted && activeRoomIsE2E) { if (peerKeyLoading) return; if (!peerE2eKey || !user?.id || !message.content) { setMediaUrls((current) => ({ ...current, [storageKey]: { ...current[storageKey], status: 'error', errorMessage: 'Не удалось расшифровать файл' } })); return; } } mediaLoadInFlightRef.current.add(storageKey); setMediaUrls((current) => ({ ...current, [storageKey]: { ...current[storageKey], status: 'loading', errorMessage: undefined } })); let meta = parseMessageMetadata(message.metadataJson); let fileName = meta.fileName || message.content || undefined; let mimeType = message.mimeType || (message.type === 'VOICE' ? 'audio/webm' : message.type === 'AUDIO' ? 'audio/mpeg' : message.type === 'VIDEO' || message.type === 'VIDEO_NOTE' ? 'video/webm' : undefined); const query = new URLSearchParams({ storageKey }); if (fileName) query.set('fileName', fileName); try { const access = await apiFetch<{ accessUrl: string; expiresAt: string }>( `/media/chat/${activeRoomId}/media/url?${query.toString()}`, {}, token ); let blob = await fetchAuthenticatedMediaBlob(access.accessUrl, token, mimeType); if (message.isEncrypted && activeRoomIsE2E && peerE2eKey && user?.id && message.content) { const payload = await decryptE2EPayload(user.id, peerE2eKey, message.content); const decryptedBuffer = await decryptE2EBlob(user.id, peerE2eKey, await blob.arrayBuffer()); mimeType = payload?.mimeType || mimeType; fileName = payload?.fileName || fileName; blob = new Blob([decryptedBuffer], { type: mimeType || 'application/octet-stream' }); } const blobUrl = createBlobObjectUrl(blob); setMediaUrls((current) => { revokeBlobObjectUrl(current[storageKey]?.blobUrl); return { ...current, [storageKey]: { blobUrl, expiresAt: mediaExpiresAtMs(access.expiresAt), fileName, mimeType, status: 'ready' } }; }); } catch (error) { setMediaUrls((current) => ({ ...current, [storageKey]: { ...current[storageKey], status: 'error', errorMessage: error instanceof Error ? error.message : 'Не удалось загрузить файл' } })); } finally { mediaLoadInFlightRef.current.delete(storageKey); } }, [activeRoomId, activeRoomIsE2E, peerE2eKey, peerKeyLoading, token, user?.id] ); const cacheUploadedMedia = useCallback((storageKey: string, file: File) => { const blobUrl = createBlobObjectUrl(file); setMediaUrls((current) => { revokeBlobObjectUrl(current[storageKey]?.blobUrl); return { ...current, [storageKey]: { blobUrl, expiresAt: Date.now() + 30 * 60_000, fileName: file.name, mimeType: file.type || resolveChatContentType(file), status: 'ready' } }; }); }, []); useEffect(() => { if (!token || !activeRoomId) return; messages.forEach((message) => { if (message.storageKey) void loadChatMedia(message); }); }, [activeRoomId, loadChatMedia, messages, peerE2eKey, peerKeyLoading, token]); useEffect(() => { if (!token || !activeRoomId) return; const timer = window.setInterval(() => { messages.forEach((message) => { if (!message.storageKey) return; const cached = mediaUrlsRef.current[message.storageKey]; if (cached?.status === 'ready' && cached.expiresAt && shouldRefreshMedia(cached.expiresAt)) { void loadChatMedia(message, true); } }); }, 60_000); return () => window.clearInterval(timer); }, [activeRoomId, loadChatMedia, messages, token]); useEffect(() => { return () => { Object.values(mediaUrlsRef.current).forEach((entry) => revokeBlobObjectUrl(entry.blobUrl)); }; }, []); async function downloadChatFile(message: ChatMessage) { if (!token || !activeRoomId || !message.storageKey) return; const storageKey = message.storageKey; const meta = parseMessageMetadata(message.metadataJson); const fileName = meta.fileName || message.content || 'file'; setDownloadingKey(storageKey); try { let blob: Blob | null = null; const cached = mediaUrls[storageKey]; if (cached?.blobUrl) { blob = await fetch(cached.blobUrl).then((response) => response.blob()); } else { const query = new URLSearchParams({ storageKey }); if (fileName) query.set('fileName', fileName); const access = await apiFetch<{ accessUrl: string }>( `/media/chat/${activeRoomId}/media/url?${query.toString()}`, {}, token ); blob = await fetchAuthenticatedMediaBlob(`${access.accessUrl}?download=1`, token); } if (!blob) { throw new Error('Не удалось получить файл'); } triggerBlobDownload(blob, fileName); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось скачать файл') ?? 'Ошибка'); } finally { setDownloadingKey(null); } } async function uploadRoomAvatar(file: File) { if (!token || !activeRoomId || roomAvatarUploading) return; setRoomAvatarUploading(true); try { const presigned = await apiFetch( `/media/chat/${activeRoomId}/avatar/upload-url`, { method: 'POST', body: JSON.stringify({ contentType: file.type }) }, token ); await uploadMediaObject(presigned, file, file.type); await apiFetch(`/media/chat/${activeRoomId}/avatar/confirm`, { method: 'POST', body: JSON.stringify({ storageKey: presigned.storageKey }) }, token); await loadGroup(); dispatchChatRoomAvatarUpdated(activeRoomId); showToast('Фото чата обновлено'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось загрузить фото чата') ?? 'Ошибка'); } finally { setRoomAvatarUploading(false); } } function applyRoomUpdate(room: ChatRoom) { setRooms((current) => current.map((item) => (item.id === room.id ? room : item))); } function sortRoomsForViewer(roomList: ChatRoom[]) { return [...roomList].sort((a, b) => { const pinDiff = Number(Boolean(b.isPinned)) - Number(Boolean(a.isPinned)); if (pinDiff !== 0) return pinDiff; if (a.isPinned && b.isPinned) { return (b.pinnedAt ?? '').localeCompare(a.pinnedAt ?? ''); } const rank = (type: string) => (type === 'GENERAL' ? 0 : 1); const rankDiff = rank(a.type) - rank(b.type); if (rankDiff !== 0) return rankDiff; const aTime = a.lastMessage?.createdAt ?? a.updatedAt; const bTime = b.lastMessage?.createdAt ?? b.updatedAt; return bTime.localeCompare(aTime); }); } async function handleToggleRoomPin(room: ChatRoom) { if (!token) return; setPinningRoomId(room.id); try { const updated = await setChatRoomPinned(room.id, !room.isPinned, token); setRooms((current) => sortRoomsForViewer(current.map((item) => (item.id === updated.id ? updated : item)))); showToast(updated.isPinned ? 'Чат закреплён' : 'Чат откреплён'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось закрепить чат') ?? 'Ошибка'); } finally { setPinningRoomId(null); } } async function handleAddRoomMember(memberUserId: string) { if (!token || !activeRoomId) return; try { const room = await addChatRoomMember(activeRoomId, memberUserId, token); applyRoomUpdate(room); setAddMembersOpen(false); showToast('Участник добавлен в чат'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось добавить участника') ?? 'Ошибка'); } } async function handleRemoveFromChat(memberUserId: string) { if (!token || !activeRoomId) return; try { const room = await removeChatRoomMember(activeRoomId, memberUserId, token); applyRoomUpdate(room); if (memberUserId === user?.id) { setActiveRoomId(null); setMembersOpen(false); } showToast('Участник удалён из чата'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось удалить участника из чата') ?? 'Ошибка'); } } async function handleRemoveFromFamily(memberId: string) { if (!token) return; try { await removeFamilyMember(memberId, token); await loadGroup(); setMembersOpen(false); setFamilyMembersOpen(false); showToast('Участник удалён из семьи'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось удалить участника из семьи') ?? 'Ошибка'); } } function openLeaveFamilyDialog() { setSelectedNewOwnerUserId(otherHumanMembers[0]?.userId ?? null); setLeaveFamilyDialogOpen(true); } function openTransferOwnershipDialog() { setSelectedNewOwnerUserId(otherHumanMembers[0]?.userId ?? null); setTransferOwnershipDialogOpen(true); } async function confirmTransferOwnership() { if (!token || !selectedNewOwnerUserId) { showToast('Выберите нового главу семьи'); return; } setTransferringOwnership(true); try { const updated = await transferFamilyOwnership(groupId, selectedNewOwnerUserId, token); setGroup(updated); setTransferOwnershipDialogOpen(false); setFamilyMembersOpen(false); showToast('Управление семьёй передано'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось передать управление семьёй') ?? 'Ошибка'); } finally { setTransferringOwnership(false); } } async function confirmLeaveFamily() { if (!token) return; if (ownerMustTransfer && !selectedNewOwnerUserId) { showToast('Выберите нового главу семьи'); return; } setLeavingFamily(true); try { const result = await leaveFamilyGroup( groupId, token, ownerMustTransfer ? selectedNewOwnerUserId ?? undefined : undefined ); setLeaveFamilyDialogOpen(false); setFamilyMembersOpen(false); showToast(result.deleted ? 'Семья удалена' : 'Вы покинули семью'); router.push('/family'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось покинуть семью') ?? 'Ошибка'); } finally { setLeavingFamily(false); } } async function createSecretChatWithMember(memberUserId: string) { if (!token) return; try { const room = await createE2ERoom(groupId, memberUserId, token); await loadGroup(); setActiveRoomId(room.id); setFamilyMembersOpen(false); showToast('Секретный E2E чат создан'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось создать секретный чат') ?? 'Ошибка'); } } async function openMemberChatFromList(memberUserId: string, options?: { isBot?: boolean; e2e?: boolean }) { if (!user || !token) return; let room = findMemberRoom(rooms, memberUserId, user.id, options); if (!room) { const roomsResponse = await fetchChatRooms(groupId, token); const roomList = roomsResponse.rooms ?? []; setRooms(roomList); room = findMemberRoom(roomList, memberUserId, user.id, options); } if (room) { setActiveRoomId(room.id); setFamilyMembersOpen(false); } else { showToast('Чат ещё создаётся. Обновите страницу через несколько секунд.'); } } async function confirmDeleteFamily() { if (!token) return; setDeletingFamily(true); try { await deleteFamilyGroup(groupId, token); setDeleteFamilyDialogOpen(false); setFamilyMembersOpen(false); showToast('Семья удалена'); router.push('/family'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось удалить семью') ?? 'Ошибка'); } finally { setDeletingFamily(false); } } async function saveFamilyName(nextName: string) { if (!token || !nextName.trim()) return; try { const updated = await updateFamilyGroup(groupId, nextName.trim(), token); setGroup(updated); showToast('Название семьи обновлено'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось переименовать семью') ?? 'Ошибка'); throw error; } } async function uploadFamilyAvatar(file: File) { if (!token || familyAvatarUploading) return; setFamilyAvatarUploading(true); try { const presigned = await apiFetch( `/media/families/${groupId}/avatar/upload-url`, { method: 'POST', body: JSON.stringify({ contentType: file.type }) }, token ); await uploadMediaObject(presigned, file, file.type); await apiFetch(`/media/families/${groupId}/avatar/confirm`, { method: 'POST', body: JSON.stringify({ storageKey: presigned.storageKey }) }, token); await loadGroup(); showToast('Аватар семьи обновлён'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось загрузить аватар') ?? 'Ошибка'); } finally { setFamilyAvatarUploading(false); } } async function submitCreateRoom() { if (!token || !newRoomName.trim()) return; try { const room = await createChatRoom(groupId, newRoomName.trim(), selectedMembers, token); setRooms((current) => [...current, room]); setActiveRoomId(room.id); setCreateRoomOpen(false); setNewRoomName(''); setSelectedMembers([]); showToast('Групповой чат создан'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось создать чат') ?? 'Ошибка'); } } async function startEditMessage(message: ChatMessage) { if (message.isDeleted) return; exitSelectionMode(); setEditingMessageId(message.id); setDraft(message.content ?? ''); setReplyToMessage(null); setEmojiOpen(false); } function cancelEditMessage() { setEditingMessageId(null); setDraft(''); } async function submitEditMessage() { if (!token || !editingMessageId) return; const content = draft.trim(); if (!content) { showToast('Сообщение не может быть пустым'); return; } setSending(true); try { const updated = await editChatMessage(editingMessageId, content, token); setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); cancelEditMessage(); void loadGroup(); } 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 (activeRoomId === roomId) { setActiveRoomId(null); setMessages([]); } await loadGroup(); showToast('Чат удалён'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось удалить чат') ?? 'Ошибка'); } finally { setDeletingRoomId(null); } } function requestDeleteMessages(messageIds: string[]) { if (!messageIds.length) return; setPendingDeleteMessageIds(messageIds); setDeleteMessagesDialogOpen(true); } async function confirmDeleteMessages() { if (!token || !pendingDeleteMessageIds.length) return; const ids = [...pendingDeleteMessageIds]; setBulkDeleting(true); setMessages((current) => current.filter((item) => !ids.includes(item.id))); try { for (const messageId of ids) { await deleteChatMessage(messageId, token); if (editingMessageId === messageId) { cancelEditMessage(); } } setSelectedMessageIds((current) => current.filter((id) => !ids.includes(id))); exitSelectionMode(); setDeleteMessagesDialogOpen(false); setPendingDeleteMessageIds([]); void loadGroup(); showToast(ids.length === 1 ? 'Сообщение удалено' : 'Сообщения удалены'); } catch (error) { if (activeRoomId) void loadMessages(activeRoomId); showToast(getApiErrorMessage(error, 'Не удалось удалить сообщение') ?? 'Ошибка'); } finally { setBulkDeleting(false); } } async function handleToggleReaction(messageId: string, emoji: string) { if (!token) return; try { const updated = await toggleChatMessageReaction(messageId, emoji, token); setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось поставить реакцию') ?? 'Ошибка'); } } 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; } exitSelectionMode(); setReplyToMessage(selected[0]!); setEditingMessageId(null); setDraft(''); setEmojiOpen(false); } function handleSelectionPin() { const selected = getSelectedMessages(); if (selected.length !== 1) { showToast('Закрепить можно только одно сообщение'); return; } void handleTogglePin(selected[0]!); } 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 handleForwardToRoom(targetRoomId: string) { if (!token) return; const ids = selectionMode ? selectedMessageIds : []; if (!ids.length) return; setForwarding(true); try { const response = await forwardChatMessages(targetRoomId, ids, token); showToast(`Переслано сообщений: ${response.messages?.length ?? ids.length}`); setForwardDialogOpen(false); exitSelectionMode(); if (targetRoomId === activeRoomId) { for (const message of response.messages ?? []) { appendMessage(message); } } void loadGroup(); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось переслать сообщения') ?? 'Ошибка'); } finally { setForwarding(false); } } function insertEmojiToDraft(nativeEmoji: string) { setDraft((current) => `${current}${nativeEmoji}`); } async function sendText(type: string = 'TEXT') { if (!token || !activeRoomId || !activeRoom || sending) return; const content = draft.trim(); if (!content && type === 'TEXT') return; if (editingMessageId && type === 'TEXT') { await submitEditMessage(); return; } setSending(true); try { let payload = content; let isEncrypted = false; if (activeRoomIsE2E) { payload = await encryptOutgoing({ kind: type === 'EMOJI' ? 'emoji' : 'text', text: content, emojiId: type === 'EMOJI' ? content : undefined }); isEncrypted = true; } const message = await sendChatMessage( activeRoomId, { type, content: payload, isEncrypted, replyToId: replyToMessage?.id }, token ); appendMessage(message); setDraft(''); setReplyToMessage(null); setEmojiOpen(false); void loadGroup(); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось отправить сообщение') ?? 'Ошибка'); } finally { setSending(false); } } async function sendLocation(payload: ChatLocationSharePayload) { if (!token || !activeRoomId || !activeRoom || sending) return; setSending(true); try { const content = payload.label?.trim() || payload.address?.trim() || `${payload.latitude.toFixed(6)}, ${payload.longitude.toFixed(6)}`; const metadataJson = JSON.stringify({ latitude: payload.latitude, longitude: payload.longitude, address: payload.address, label: payload.label }); let messageContent = content; let isEncrypted = false; if (activeRoomIsE2E) { messageContent = await encryptOutgoing({ kind: 'location', text: content, latitude: payload.latitude, longitude: payload.longitude, address: payload.address, label: payload.label }); isEncrypted = true; } const message = await sendChatMessage( activeRoomId, { type: 'LOCATION', content: messageContent, isEncrypted, metadataJson: activeRoomIsE2E ? undefined : metadataJson, replyToId: replyToMessage?.id }, token ); appendMessage(message); setLocationShareOpen(false); setReplyToMessage(null); void loadGroup(); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось отправить геопозицию') ?? 'Ошибка'); } finally { setSending(false); } } async function uploadChatFile(file: File, options?: { voice?: boolean; videoNote?: boolean; durationMs?: number }) { if (!token || !activeRoomId || !activeRoom) return; const contentType = resolveChatContentType(file); const isDirectUpload = Boolean(options?.voice || options?.videoNote || contentType.startsWith('video/')); if (!isDirectUpload) { queueMediaFiles([file]); return; } setSending(true); try { const messageType = detectChatMessageType(file, { voice: options?.voice, videoNote: options?.videoNote }); const metadata = { fileName: file.name, fileSize: file.size, ...(options?.durationMs ? { durationMs: options.durationMs } : {}), ...(options?.videoNote ? { videoNote: true } : {}) }; let uploadFile = file; let uploadContentType = contentType; let messageContent: string | undefined = messageType === 'VOICE' ? 'Голосовое сообщение' : messageType === 'VIDEO' ? 'Видео' : messageType === 'VIDEO_NOTE' ? 'Видеосообщение' : 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, durationMs: options?.durationMs }); isEncrypted = true; } const presigned = await apiFetch( `/media/chat/${activeRoomId}/media/upload-url`, { method: 'POST', body: JSON.stringify({ contentType: uploadContentType, fileName: uploadFile.name }) }, token ); await uploadMediaObject(presigned, uploadFile, uploadContentType); const message = await sendChatMessage( activeRoomId, { type: messageType, storageKey: presigned.storageKey, mimeType: uploadContentType, content: messageContent, isEncrypted, metadataJson: JSON.stringify(activeRoomIsE2E ? { e2e: true, ...metadata } : metadata) }, token ); appendMessage(message); cacheUploadedMedia(presigned.storageKey, file); void loadGroup(); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось отправить файл') ?? 'Ошибка'); } finally { setSending(false); } } 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, index) => { appendMessage(message); const sourceFile = mediaComposer.items[index]?.file; if (message.storageKey && sourceFile) { cacheUploadedMedia(message.storageKey, sourceFile); } }); 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); if (!pollQuestion.trim() || options.length < 2) { showToast('Укажите вопрос и минимум 2 варианта'); return; } setSending(true); try { const message = await sendChatMessage( activeRoomId, { type: 'POLL', poll: { question: pollQuestion.trim(), options } }, token ); appendMessage(message); setPollOpen(false); setPollQuestion(''); setPollOptions(['', '']); void loadGroup(); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось создать опрос') ?? 'Ошибка'); } finally { setSending(false); } } async function toggleMute() { if (!token || !activeRoomId || !activeRoom) return; const muted = !myMembership?.notificationsMuted; await setChatRoomMuted(activeRoomId, muted, token); setRooms((current) => current.map((room) => room.id === activeRoomId ? { ...room, members: room.members.map((member) => member.userId === user?.id ? { ...member, notificationsMuted: muted } : member ) } : room ) ); showToast(muted ? 'Уведомления чата выключены' : 'Уведомления чата включены'); } async function startVoiceRecording() { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const preferredTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg']; const mimeType = preferredTypes.find((type) => MediaRecorder.isTypeSupported(type)) ?? ''; const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream); const chunks: BlobPart[] = []; recorder.ondataavailable = (event) => { if (event.data.size > 0) chunks.push(event.data); }; recorder.onstop = () => { stream.getTracks().forEach((track) => track.stop()); const durationMs = recordingStartedAtRef.current ? Date.now() - recordingStartedAtRef.current : undefined; recordingStartedAtRef.current = null; const blob = new Blob(chunks, { type: 'audio/webm' }); void uploadChatFile(new File([blob], `voice-${Date.now()}.webm`, { type: 'audio/webm' }), { voice: true, durationMs }); }; mediaRecorderRef.current = recorder; recordingStartedAtRef.current = Date.now(); recorder.start(); setRecording(true); } catch { showToast('Не удалось получить доступ к микрофону'); } } function stopVoiceRecording() { mediaRecorderRef.current?.stop(); mediaRecorderRef.current = null; setRecording(false); } if (!isReady || loading) { return (
Загрузка семьи...
); } return (
{activeRoom ? ( <>
{(activeRoom.type === 'GROUP' || activeRoom.type === 'GENERAL') ? ( ) : (
)}
{!activeRoomIsBot ? ( <> ) : null} {canManageActiveBot ? ( ) : null} {activeRoom.type !== 'GENERAL' ? ( ) : null}
{activeRoomIsBot && activeRoom.botUsername ? ( { setMiniAppTitle('Mini App'); setMiniAppUrl(url); }} /> ) : (
{ 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 }); }} > { 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}
{visibleMessages.map((message, messageIndex) => { if (shouldSkipGroupedAlbumMessage(visibleMessages, messageIndex)) { return null; } 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 forwardedFrom = parseForwardedFrom(message.metadataJson); const repliedMessage = message.replyToId ? messageById.get(message.replyToId) : null; const repliedVisibleText = repliedMessage ? getE2EMessageText({ message: repliedMessage, isE2E: activeRoomIsE2E, peerE2eKey, peerKeyLoading, e2ePayload: repliedMessage.isEncrypted ? e2ePayloads[repliedMessage.id] : null, e2eError: e2eErrors[repliedMessage.id] }) : ''; 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 albumMessages = collectAlbumMessages(visibleMessages, messageIndex); const isAlbumGroup = albumMessages.length > 1 && Boolean(parseChatMediaMetadata(message.metadataJson).albumId); const bubble = (
{!mine ?

{message.senderName}

: null} {forwardedFrom ? (

↪️ Переслано от {forwardedFrom.senderName}

) : null} {repliedMessage ? ( scrollToChatMessage(repliedMessage.id)} className={mine ? 'bg-black/[0.06]' : undefined} /> ) : 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.type === 'LOCATION' ? ( (() => { const locationMeta = parseLocationMetadata(message.metadataJson, e2ePayload); if (!locationMeta || locationMeta.latitude == null || locationMeta.longitude == null) { return

Геопозиция недоступна

; } return ( ); })() ) : message.storageKey ? ( (() => { if (isAlbumGroup) { const captionMessage = albumMessages.find((item) => item.content?.trim()); return (
openImageLightbox(item)} /> {captionMessage?.content ? (

{captionMessage.content}

) : null}
); } const media = mediaUrls[message.storageKey]; const meta = parseMessageMetadata(message.metadataJson); const fileName = e2ePayload?.fileName || meta.fileName || message.content || 'Файл'; if (media?.status === 'error') { return (

{media.errorMessage ?? 'Не удалось загрузить файл'}

); } if (!media?.blobUrl || media.status === 'loading') { return ( ); } if (message.type === 'IMAGE') { return ( ); } if (message.type === 'VOICE' || message.type === 'AUDIO') { const metaDuration = e2ePayload?.durationMs ?? parseMessageMetadata(message.metadataJson).durationMs; return ( ); } if (message.type === 'VIDEO') { const metaDuration = e2ePayload?.durationMs ?? parseMessageMetadata(message.metadataJson).durationMs; return ( ); } if (message.type === 'VIDEO_NOTE') { const metaDuration = e2ePayload?.durationMs ?? parseMessageMetadata(message.metadataJson).durationMs; return ( ); } return ( ); })() ) : isLargeEmoji ? (
{largeEmojiNative}
{message.editedAt ? изм. : null} {formatMessageTime(message.createdAt)} {mine ? ( message.readByAll ? ( ) : ( ) ) : null}
) : emojiContent ? (
) : (
)} {!isLargeEmoji ? (
{message.editedAt ? изменено : null} {formatMessageTime(message.createdAt)} {mine ? ( message.readByAll ? ( ) : ( ) ) : null}
) : null} {!isLargeEmoji ? ( void handleToggleReaction(message.id, emoji)} /> ) : 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={() => setForwardDialogOpen(true)} onDelete={() => void handleBulkDelete()} onTogglePin={handleSelectionPin} onCancel={exitSelectionMode} > toggleSelectedMessage(message.id)} > {bubble} ) : ( void startEditMessage(message)} onDelete={() => requestDeleteMessages([message.id])} onReply={() => { exitSelectionMode(); setReplyToMessage(message); setEditingMessageId(null); setDraft(''); setEmojiOpen(false); }} onForward={() => { setSelectedMessageIds([message.id]); setForwardDialogOpen(true); }} onTogglePin={() => void handleTogglePin(message)} onSelect={() => enterSelectionMode(message.id)} onToggleReaction={(emoji) => void handleToggleReaction(message.id, emoji)} onCopy={() => showToast('Текст скопирован')} > {bubble} )} {mine ? ( ) : null}
); })}
{selectionMode ? ( void handleBulkCopy()} onForward={() => setForwardDialogOpen(true)} onDelete={() => void handleBulkDelete()} /> ) : (
{editingMessageId ? ( item.id === editingMessageId)?.content || undefined} onCancel={cancelEditMessage} /> ) : null} {replyToMessage ? (

Ответ для {replyToMessage.senderName}

{getMessageCopyText(replyToMessage) || 'Сообщение'}

) : null} {emojiOpen ? (
insertEmojiToDraft(nativeEmoji)} />
) : null}
{!activeRoomIsBot ? ( ) : null} {!activeRoomIsE2E ? ( ) : null}