'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { ArrowLeft, BarChart3, CalendarDays, Camera, Check, CheckCheck, FileText, ImageIcon, Loader2, Mic, Paperclip, Plus, Search, Send, Settings, Smile, Trash2, UserPlus, Users, 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 { 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, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu'; 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 { 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 { 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, markChatRoomRead, removeChatRoomMember, removeFamilyMember, reportChatTyping, sendChatMessage, setChatRoomMuted, toggleChatMessageReaction, 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, parseMessageMetadata, resolveChatContentType } from '@/lib/chat-media'; interface PresignedUploadResponse { uploadUrl: string; storageKey: string; expiresAt?: string; uploadToken?: string; } interface LoadedChatMedia { blobUrl: string; expiresAt: number; fileName?: string; mimeType?: 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 [url, setUrl] = useState(null); useEffect(() => { if (!hasAvatar || !token) { setUrl(null); return; } apiFetch<{ accessUrl: string }>(`/media/families/${groupId}/avatar/url`, {}, token) .then((response) => setUrl(response.accessUrl)) .catch(() => setUrl(null)); }, [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' ? 'Создатель семьи' : 'Участник'; } export function FamilyGroupView({ groupId }: { groupId: string }) { const router = useRouter(); const { user, token } = 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 [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 [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 messagesEndRef = useRef(null); 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 fileInputRef = useRef(null); 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 messageById = useMemo( () => new Map(visibleMessages.map((message) => [message.id, message])), [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 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 isChatCreator = activeRoom?.createdById === user?.id; const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator); 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?.type === 'BOT') return; const response = await fetchChatMessages(roomId, token); setMessages((response.messages ?? []).filter((message) => !message.isDeleted)); }, [isPinLocked, rooms, token]); const loadPresence = useCallback(async () => { if (!token || isPinLocked) return; try { const response = await fetchFamilyPresence(groupId, token); setPresenceMembers(response.members ?? []); } catch { // ignore background presence errors } }, [groupId, isPinLocked, token]); useEffect(() => { if (!isReady || !token || isPinLocked) return; setLoading(true); loadGroup() .catch((error) => showToast(getApiErrorMessage(error, 'Не удалось загрузить семью') ?? 'Ошибка')) .finally(() => setLoading(false)); void loadPresence(); }, [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(() => { if (!token || isPinLocked) return; void loadPresence(); const timer = window.setInterval(() => void loadPresence(), 25_000); return () => window.clearInterval(timer); }, [connected, isPinLocked, loadPresence, token]); useEffect(() => { if (!activeRoomId || activeRoomIsBot) return; void loadMessages(activeRoomId); setEditingMessageId(null); setDraft(''); }, [activeRoomId, activeRoomIsBot, loadMessages]); 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(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); 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'); } }); }, [ 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 && !shouldRefreshMedia(existing.expiresAt)) return; 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' : 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 } }; }); } catch { // ignore failed media loads in background } }, [activeRoomId, activeRoomIsE2E, peerE2eKey, token, user?.id] ); useEffect(() => { if (!token || !activeRoomId) return; messages.forEach((message) => { if (message.storageKey) void loadChatMedia(message); }); }, [activeRoomId, loadChatMedia, messages, 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 && 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))); } 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); const removedMember = group?.members?.find((item) => item.id === memberId); if (removedMember?.userId === user?.id) { showToast('Вы вышли из семьи'); router.push('/family'); return; } await loadGroup(); setMembersOpen(false); setFamilyMembersOpen(false); showToast('Участник удалён из семьи'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось удалить участника из семьи') ?? 'Ошибка'); } } 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, 'Не удалось поставить реакцию') ?? 'Ошибка'); } } 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 uploadChatFile(file: File, options?: { voice?: boolean; durationMs?: number }) { if (!token || !activeRoomId || !activeRoom) return; setSending(true); try { const contentType = resolveChatContentType(file); const messageType = detectChatMessageType(file, { voice: options?.voice }); const metadata = { fileName: file.name, fileSize: file.size, ...(options?.durationMs ? { durationMs: options.durationMs } : {}) }; let uploadFile = file; let uploadContentType = contentType; let messageContent: string | undefined = messageType === 'VOICE' ? 'Голосовое сообщение' : 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); void loadGroup(); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось отправить файл') ?? 'Ошибка'); } finally { setSending(false); } } 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); }} /> ) : ( <>
{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 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 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.storageKey ? ( (() => { const media = mediaUrls[message.storageKey]; const meta = parseMessageMetadata(message.metadataJson); const fileName = e2ePayload?.fileName || meta.fileName || message.content || 'Файл'; if (!media?.blobUrl) { return (
Загрузка файла...
); } if (message.type === 'IMAGE') { return ( // eslint-disable-next-line @next/next/no-img-element {fileName} ); } if (message.type === 'VOICE' || message.type === 'AUDIO') { 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 ? ( 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); }} 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}
{!activeRoomIsE2E ? ( ) : null}