'use client'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from 'next/navigation'; import { ArrowLeft, BarChart3, Camera, FileText, Loader2, Mic, Paperclip, Plus, Send, Smile, UserPlus, Volume2, VolumeX } from 'lucide-react'; 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 { Input } from '@/components/ui/input'; import { useRequireAuth } from '@/hooks/use-require-auth'; import { apiFetch, ChatMessage, ChatRoom, createChatRoom, FamilyGroup, FamilyInviteCandidate, fetchChatMessages, fetchChatRooms, fetchFamilyGroup, getApiErrorMessage, searchFamilyInviteUsers, sendChatMessage, sendFamilyInvite, 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; } function formatMessageTime(value: string) { return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value)); } 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()} ); } export function FamilyGroupView({ groupId }: { groupId: string }) { const router = useRouter(); const { user, token } = useAuth(); const { isReady, isPinLocked } = useRequireAuth(); const { showToast } = useToast(); const { subscribe } = 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 [inviteQuery, setInviteQuery] = useState(''); const [inviteResults, setInviteResults] = useState([]); const [selectedInviteUser, setSelectedInviteUser] = useState(null); const [inviteSearching, setInviteSearching] = 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 messagesEndRef = useRef(null); const mediaRecorderRef = useRef(null); const recordingStartedAtRef = useRef(null); const mediaUrlsRef = useRef>({}); const avatarInputRef = 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 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]); useEffect(() => { if (!isReady || !token || isPinLocked) return; setLoading(true); loadGroup() .catch((error) => showToast(getApiErrorMessage(error, 'Не удалось загрузить семью') ?? 'Ошибка')) .finally(() => setLoading(false)); }, [isPinLocked, isReady, loadGroup, showToast, token]); useEffect(() => { if (!activeRoomId) return; void loadMessages(activeRoomId); }, [activeRoomId, loadMessages]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); useEffect(() => { return subscribe((event) => { if (event.type !== 'chat_message') return; const payload = event.payload; if (!payload?.roomId || payload.roomId !== activeRoomId) return; if (payload.message) { appendMessage(payload.message); } else { void loadMessages(activeRoomId!); } void loadGroup(); }); }, [activeRoomId, appendMessage, loadGroup, loadMessages, 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 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); const blobUrl = createBlobObjectUrl(blob); setMediaUrls((current) => { revokeBlobObjectUrl(current[storageKey]?.blobUrl); return { ...current, [storageKey]: { blobUrl, expiresAt: mediaExpiresAtMs(access.expiresAt), fileName } }; }); } 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 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 submitInvite() { if (!token || !selectedInviteUser) return; try { await sendFamilyInvite(groupId, { inviteeUserId: selectedInviteUser.id }, token); setInviteOpen(false); setInviteQuery(''); setInviteResults([]); setSelectedInviteUser(null); showToast('Приглашение отправлено'); } catch (error) { showToast(getApiErrorMessage(error, 'Не удалось отправить приглашение') ?? 'Ошибка'); } } useEffect(() => { if (!inviteOpen || !token || selectedInviteUser) { return; } const query = inviteQuery.trim(); if (query.length < 2) { setInviteResults([]); return; } const timer = window.setTimeout(() => { setInviteSearching(true); void searchFamilyInviteUsers(groupId, query, token) .then((response) => setInviteResults(response.users ?? [])) .catch(() => setInviteResults([])) .finally(() => setInviteSearching(false)); }, 300); return () => window.clearTimeout(timer); }, [groupId, inviteOpen, inviteQuery, selectedInviteUser, token]); 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 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.name}

{activeRoom.members.length} участников

{messages.map((message) => { const mine = message.senderId === user?.id; return (
{!mine ?

{message.senderName}

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

{message.poll.question}

{message.poll.options.map((option) => { const total = message.poll!.options.reduce((sum, item) => sum + item.voteCount, 0) || 1; const percent = Math.round((option.voteCount / total) * 100); return ( ); })}
) : message.storageKey ? ( (() => { const media = mediaUrls[message.storageKey]; const meta = parseMessageMetadata(message.metadataJson); const 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') { return (
{message.type === 'VOICE' ? : }

{message.type === 'VOICE' ? 'Голосовое сообщение' : 'Аудиофайл'}

); } return ( ); })() ) : (

{message.content}

)}

{formatMessageTime(message.createdAt)}

); })}
{emojiOpen ? (
{EMOJIS.map((emoji) => ( ))}
) : null}