'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { ArrowLeft, BarChart3, Camera, Check, CheckCheck, FileText, Loader2, Mic, MoreVertical, Paperclip, Pencil, Plus, Send, Smile, Trash2, UserPlus, Users, Volume2, VolumeX } from 'lucide-react'; import { AvatarWithPresence } from '@/components/family/avatar-with-presence'; import { FamilyInviteDialog } from '@/components/family/family-invite-dialog'; 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { useRequireAuth } from '@/hooks/use-require-auth'; import { addChatRoomMember, apiFetch, ChatMessage, ChatRoom, createChatRoom, deleteChatMessage, deleteFamilyGroup, editChatMessage, FamilyGroup, FamilyPresenceMember, fetchChatMessages, fetchChatRooms, fetchFamilyGroup, fetchFamilyPresence, getApiErrorMessage, markChatRoomRead, removeChatRoomMember, removeFamilyMember, sendChatMessage, setChatRoomMuted, updateFamilyGroup, uploadMediaObject, voteChatPoll } from '@/lib/api'; 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'; const EMOJIS = ['😀', '😂', '❤️', '👍', '🎉', '🔥', '😍', '🙏', '👋', '🤔', '😢', '😎']; 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 messagePreview(message?: ChatMessage) { if (!message) return ''; if (message.isDeleted) return 'Сообщение удалено'; if (message.type === 'POLL') return '📊 Опрос'; if (message.type === 'VOICE') return '🎤 Голосовое'; if (message.type === 'AUDIO') return '🎵 Аудио'; if (message.type === 'IMAGE') return '📷 Фото'; if (message.type === 'FILE') return '📄 Файл'; return message.content ?? ''; } function FamilyAvatar({ groupId, name, hasAvatar, token }: { groupId: string; name: string; hasAvatar?: boolean; token: string | null }) { const [url, setUrl] = useState(null); useEffect(() => { if (!hasAvatar || !token) return; apiFetch<{ accessUrl: string }>(`/media/families/${groupId}/avatar/url`, {}, token) .then((response) => setUrl(response.accessUrl)) .catch(() => setUrl(null)); }, [groupId, hasAvatar, token]); return ( {url ? : null} {name.slice(0, 2).toUpperCase()} ); } function ChatRoomAvatar({ roomId, name, hasAvatar, token }: { roomId: string; name: string; hasAvatar?: boolean; token: string | null }) { const [url, setUrl] = useState(null); useEffect(() => { if (!hasAvatar || !token) return; apiFetch<{ accessUrl: string }>(`/media/chat/${roomId}/avatar/url`, {}, token) .then((response) => setUrl(response.accessUrl)) .catch(() => setUrl(null)); }, [hasAvatar, roomId, token]); return ( {url ? : null} {name.slice(0, 2).toUpperCase()} ); } 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(null); 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 [renameValue, setRenameValue] = useState(''); 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 [editDraft, setEditDraft] = useState(''); const [presenceMembers, setPresenceMembers] = useState([]); const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false); const [deletingFamily, setDeletingFamily] = useState(false); const messagesEndRef = useRef(null); const readReceiptTimerRef = useRef | null>(null); const mediaRecorderRef = useRef(null); const recordingStartedAtRef = useRef(null); const mediaUrlsRef = useRef>({}); const avatarInputRef = useRef(null); const roomAvatarInputRef = useRef(null); const fileInputRef = useRef(null); useEffect(() => { mediaUrlsRef.current = mediaUrls; }, [mediaUrls]); const appendMessage = useCallback((message: ChatMessage) => { setMessages((current) => (current.some((item) => item.id === message.id) ? current : [...current, message])); }, []); const activeRoom = useMemo(() => rooms.find((room) => room.id === activeRoomId) ?? null, [activeRoomId, rooms]); 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 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); setRenameValue(groupResponse.name); const roomList = roomsResponse.rooms ?? []; setRooms(roomList); setActiveRoomId((current) => current ?? roomList[0]?.id ?? null); }, [groupId, isPinLocked, token]); const loadMessages = useCallback(async (roomId: string) => { if (!token || isPinLocked) return; const response = await fetchChatMessages(roomId, token); setMessages(response.messages ?? []); }, [isPinLocked, 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(() => { 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) return; void loadMessages(activeRoomId); setEditingMessageId(null); setEditDraft(''); }, [activeRoomId, 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(() => { return subscribe((event) => { const payload = event.payload; if (payload?.roomId && payload.roomId !== activeRoomId) { if (event.type === 'chat_message') void loadGroup(); return; } if (event.type === 'chat_message') { if (payload?.message) { appendMessage(payload.message); } else if (activeRoomId) { void loadMessages(activeRoomId); } void loadGroup(); return; } if (event.type === 'chat_message_updated' || event.type === 'chat_message_deleted') { const updated = payload?.message; if (updated) { setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); } else if (activeRoomId) { void loadMessages(activeRoomId); } void loadGroup(); return; } if (event.type === 'chat_read_receipt' && activeRoomId) { 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, appendMessage, groupId, loadGroup, loadMessages, router, 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; const meta = parseMessageMetadata(message.metadataJson); const fileName = meta.fileName || message.content || undefined; const 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 ); const blob = await fetchAuthenticatedMediaBlob(access.accessUrl, token, mimeType); 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, token] ); 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) return; 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(); showToast('Фото чата обновлено'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось загрузить фото чата') ?? 'Ошибка'); } } 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 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 saveRename() { if (!token || !renameValue.trim()) return; try { const updated = await updateFamilyGroup(groupId, renameValue.trim(), token); setGroup(updated); showToast('Название семьи обновлено'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось переименовать семью') ?? 'Ошибка'); } } async function uploadFamilyAvatar(file: File) { if (!token) return; 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, 'Не удалось загрузить аватар') ?? 'Ошибка'); } } 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; setEditingMessageId(message.id); setEditDraft(message.content ?? ''); } async function submitEditMessage() { if (!token || !editingMessageId) return; const content = editDraft.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))); setEditingMessageId(null); setEditDraft(''); void loadGroup(); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось изменить сообщение') ?? 'Ошибка'); } finally { setSending(false); } } async function handleDeleteMessage(messageId: string) { if (!token) return; setSending(true); try { const updated = await deleteChatMessage(messageId, token); setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item))); if (editingMessageId === messageId) { setEditingMessageId(null); setEditDraft(''); } void loadGroup(); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось удалить сообщение') ?? 'Ошибка'); } finally { setSending(false); } } async function sendText(type: string = 'TEXT') { if (!token || !activeRoomId || sending) return; const content = draft.trim(); if (!content && type === 'TEXT') return; setSending(true); try { const message = await sendChatMessage(activeRoomId, { type, content }, token); appendMessage(message); setDraft(''); 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) 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 } : {}) }; const presigned = await apiFetch( `/media/chat/${activeRoomId}/media/upload-url`, { method: 'POST', body: JSON.stringify({ contentType, fileName: file.name }) }, token ); await uploadMediaObject(presigned, file, contentType); const message = await sendChatMessage( activeRoomId, { type: messageType, storageKey: presigned.storageKey, mimeType: contentType, content: messageType === 'VOICE' ? 'Голосовое сообщение' : messageType === 'FILE' ? file.name : undefined, metadataJson: JSON.stringify(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' ? ( ) : null}
{messages.map((message) => { const mine = message.senderId === user?.id; const isEditing = editingMessageId === message.id; const canEdit = mine && !message.isDeleted && (message.type === 'TEXT' || message.type === 'EMOJI'); return (
{!mine ? ( ) : null}
{canEdit ? ( void startEditMessage(message)}> Изменить void handleDeleteMessage(message.id)} > Удалить ) : null} {!mine ?

{message.senderName}

: null} {message.isDeleted ? (

Сообщение удалено

) : isEditing ? (