Files
IdP/apps/frontend/components/family/family-group-view.tsx
2026-06-24 20:15:19 +03:00

892 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<string | null>(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 (
<Avatar className="h-11 w-11">
{url ? <AvatarImage src={url} alt={name} /> : null}
<AvatarFallback>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
);
}
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<FamilyGroup | null>(null);
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [activeRoomId, setActiveRoomId] = useState<string | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
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<FamilyInviteCandidate[]>([]);
const [selectedInviteUser, setSelectedInviteUser] = useState<FamilyInviteCandidate | null>(null);
const [inviteSearching, setInviteSearching] = useState(false);
const [renameValue, setRenameValue] = useState('');
const [newRoomName, setNewRoomName] = useState('');
const [selectedMembers, setSelectedMembers] = useState<string[]>([]);
const [pollQuestion, setPollQuestion] = useState('');
const [pollOptions, setPollOptions] = useState(['', '']);
const [emojiOpen, setEmojiOpen] = useState(false);
const [recording, setRecording] = useState(false);
const [mediaUrls, setMediaUrls] = useState<Record<string, LoadedChatMedia>>({});
const [downloadingKey, setDownloadingKey] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const recordingStartedAtRef = useRef<number | null>(null);
const mediaUrlsRef = useRef<Record<string, LoadedChatMedia>>({});
const avatarInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(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<PresignedUploadResponse>(
`/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<PresignedUploadResponse>(
`/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 <div className="py-20 text-center text-[#667085]">Загрузка семьи...</div>;
}
return (
<div className="flex h-[calc(100vh-120px)] min-h-[640px] overflow-hidden rounded-[28px] border border-[#eceef4] bg-[#eef2f8]">
<aside className="flex w-[300px] shrink-0 flex-col border-r border-[#e4e8ef] bg-white">
<div className="border-b border-[#eceef4] p-4">
<button type="button" className="mb-3 flex items-center gap-2 text-sm text-[#667085]" onClick={() => router.push('/family')}>
<ArrowLeft className="h-4 w-4" />
Все семьи
</button>
<div className="flex items-center gap-3">
<button type="button" onClick={() => avatarInputRef.current?.click()}>
<FamilyAvatar groupId={groupId} name={group?.name ?? 'С'} hasAvatar={group?.hasAvatar} token={token} />
</button>
<div className="min-w-0 flex-1">
<Input value={renameValue} onChange={(event) => setRenameValue(event.target.value)} className="h-9" />
<Button size="sm" variant="secondary" className="mt-2 h-8 rounded-xl" onClick={() => void saveRename()}>
Сохранить название
</Button>
</div>
</div>
<div className="mt-3 flex gap-2">
<Button size="sm" variant="secondary" className="flex-1 rounded-xl" onClick={() => setInviteOpen(true)}>
<UserPlus className="mr-1 h-4 w-4" />
Пригласить
</Button>
<Button size="sm" variant="secondary" className="rounded-xl" onClick={() => avatarInputRef.current?.click()}>
<Camera className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex items-center justify-between px-4 py-3">
<p className="text-sm font-semibold text-[#667085]">Чаты</p>
<Button size="sm" variant="ghost" className="h-8 w-8 rounded-xl p-0" onClick={() => setCreateRoomOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto">
{rooms.map((room) => (
<button
key={room.id}
type="button"
onClick={() => setActiveRoomId(room.id)}
className={cn(
'flex w-full items-start gap-3 px-4 py-3 text-left transition hover:bg-[#f7f9fc]',
activeRoomId === room.id && 'bg-[#3390ec]/10'
)}
>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[#3390ec] text-sm font-semibold text-white">
{room.name.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate font-medium">{room.name}</p>
{room.lastMessage ? (
<span className="shrink-0 text-[11px] text-[#a8adbc]">{formatMessageTime(room.lastMessage.createdAt)}</span>
) : null}
</div>
<p className="truncate text-sm text-[#667085]">
{room.lastMessage?.content ?? (room.type === 'GENERAL' ? 'Общий чат семьи' : 'Нет сообщений')}
</p>
</div>
</button>
))}
</div>
</aside>
<section className="flex min-w-0 flex-1 flex-col bg-[#e6ebf2]">
{activeRoom ? (
<>
<div className="flex items-center justify-between border-b border-[#dce3ec] bg-white px-4 py-3">
<div>
<h2 className="font-semibold">{activeRoom.name}</h2>
<p className="text-xs text-[#667085]">{activeRoom.members.length} участников</p>
</div>
<Button size="sm" variant="secondary" className="rounded-xl" onClick={() => void toggleMute()}>
{myMembership?.notificationsMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</Button>
</div>
<div className="flex-1 space-y-2 overflow-y-auto px-4 py-4">
{messages.map((message) => {
const mine = message.senderId === user?.id;
return (
<div key={message.id} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[78%] rounded-[18px] px-3 py-2 shadow-sm',
mine ? 'rounded-br-md bg-[#effdde] text-[#1f2430]' : 'rounded-bl-md bg-white text-[#1f2430]'
)}
>
{!mine ? <p className="mb-1 text-xs font-semibold text-[#3390ec]">{message.senderName}</p> : null}
{message.type === 'POLL' && message.poll ? (
<div className="space-y-2">
<p className="font-medium">{message.poll.question}</p>
{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 (
<button
key={option.id}
type="button"
className="relative w-full overflow-hidden rounded-xl border border-[#dce3ec] px-3 py-2 text-left text-sm"
onClick={() => void voteChatPoll(message.id, [option.id], token).then((updated) => {
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
})}
>
<div className="absolute inset-y-0 left-0 bg-[#3390ec]/15" style={{ width: `${percent}%` }} />
<span className="relative flex justify-between gap-2">
<span>{option.text}</span>
<span>{percent}%</span>
</span>
</button>
);
})}
</div>
) : message.storageKey ? (
(() => {
const media = mediaUrls[message.storageKey];
const meta = parseMessageMetadata(message.metadataJson);
const fileName = meta.fileName || message.content || 'Файл';
if (!media?.blobUrl) {
return (
<div className="flex items-center gap-2 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка файла...
</div>
);
}
if (message.type === 'IMAGE') {
return (
// eslint-disable-next-line @next/next/no-img-element
<img src={media.blobUrl} alt={fileName} className="max-h-72 max-w-full rounded-xl object-cover" />
);
}
if (message.type === 'VOICE' || message.type === 'AUDIO') {
return (
<div className="flex min-w-[220px] items-center gap-3 rounded-xl bg-black/5 px-3 py-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#3390ec] text-white">
{message.type === 'VOICE' ? <Mic className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-[#667085]">
{message.type === 'VOICE' ? 'Голосовое сообщение' : 'Аудиофайл'}
</p>
<audio controls preload="metadata" src={media.blobUrl} className="mt-1 h-8 w-full max-w-[240px]" />
</div>
</div>
);
}
return (
<button
type="button"
disabled={downloadingKey === message.storageKey}
onClick={() => void downloadChatFile(message)}
className="flex min-w-[220px] items-center gap-3 rounded-xl bg-black/5 px-3 py-2 text-left transition hover:bg-black/10 disabled:opacity-70"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#3390ec] text-xs font-bold text-white">
{downloadingKey === message.storageKey ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
fileIconLabel(fileName, message.mimeType)
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{fileName}</p>
<p className="text-xs text-[#667085]">{formatFileSize(meta.fileSize) || 'Документ'}</p>
</div>
<FileText className="h-4 w-4 shrink-0 text-[#667085]" />
</button>
);
})()
) : (
<p className="whitespace-pre-wrap break-words text-[15px] leading-relaxed">{message.content}</p>
)}
<p className={cn('mt-1 text-[11px]', mine ? 'text-[#7ea06a]' : 'text-[#a8adbc]')}>
{formatMessageTime(message.createdAt)}
</p>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
<div className="border-t border-[#dce3ec] bg-white px-3 py-3">
{emojiOpen ? (
<div className="mb-2 flex flex-wrap gap-1 rounded-2xl bg-[#f4f5f8] p-2">
{EMOJIS.map((emoji) => (
<button
key={emoji}
type="button"
className="rounded-lg px-2 py-1 text-xl hover:bg-white"
onClick={() => setDraft((current) => `${current}${emoji}`)}
>
{emoji}
</button>
))}
</div>
) : null}
<div className="flex items-end gap-2">
<Button size="sm" variant="ghost" className="h-10 w-10 rounded-xl p-0" onClick={() => fileInputRef.current?.click()}>
<Paperclip className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="h-10 w-10 rounded-xl p-0" onClick={() => setPollOpen(true)}>
<BarChart3 className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="h-10 w-10 rounded-xl p-0" onClick={() => setEmojiOpen((value) => !value)}>
<Smile className="h-4 w-4" />
</Button>
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Сообщение"
rows={1}
className="max-h-32 min-h-[44px] flex-1 resize-none rounded-[20px] border border-[#dce3ec] bg-[#f4f5f8] px-4 py-3 text-[15px] outline-none focus:border-[#3390ec]"
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void sendText();
}
}}
/>
{draft.trim() ? (
<Button className="h-11 w-11 rounded-full p-0" disabled={sending} onClick={() => void sendText()}>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
) : (
<Button
className={cn('h-11 w-11 rounded-full p-0', recording && 'bg-red-500 hover:bg-red-600')}
onMouseDown={() => void startVoiceRecording()}
onMouseUp={stopVoiceRecording}
onMouseLeave={stopVoiceRecording}
onTouchStart={() => void startVoiceRecording()}
onTouchEnd={stopVoiceRecording}
>
<Mic className="h-4 w-4" />
</Button>
)}
</div>
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center text-[#667085]">Выберите чат</div>
)}
</section>
<input ref={avatarInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
const file = event.target.files?.[0];
if (file) void uploadFamilyAvatar(file);
event.target.value = '';
}} />
<input ref={fileInputRef} type="file" className="hidden" onChange={(event) => {
const file = event.target.files?.[0];
if (file) void uploadChatFile(file);
event.target.value = '';
}} />
<Dialog open={inviteOpen} onOpenChange={(open) => {
setInviteOpen(open);
if (!open) {
setInviteQuery('');
setInviteResults([]);
setSelectedInviteUser(null);
}
}}>
<DialogContent className="rounded-[28px] sm:max-w-[420px]">
<DialogHeader><DialogTitle>Пригласить в семью</DialogTitle></DialogHeader>
{selectedInviteUser ? (
<div className="rounded-2xl bg-[#f4f5f8] px-4 py-3">
<p className="font-medium">{selectedInviteUser.displayName}</p>
<p className="text-sm text-[#667085]">
{selectedInviteUser.email ?? selectedInviteUser.phone ?? selectedInviteUser.username ?? 'Контакт не указан'}
</p>
<button type="button" className="mt-2 text-sm text-[#667085] underline" onClick={() => setSelectedInviteUser(null)}>
Выбрать другого пользователя
</button>
</div>
) : (
<>
<Input
placeholder="ФИО, почта, телефон или логин"
value={inviteQuery}
onChange={(event) => setInviteQuery(event.target.value)}
/>
<div className="mt-2 max-h-56 overflow-y-auto rounded-2xl border border-[#eceef4]">
{inviteSearching ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Ищем пользователей...
</div>
) : inviteQuery.trim().length < 2 ? (
<p className="px-4 py-3 text-sm text-[#667085]">Введите минимум 2 символа для поиска</p>
) : inviteResults.length ? (
inviteResults.map((candidate) => (
<button
key={candidate.id}
type="button"
className="flex w-full items-center gap-3 border-b border-[#eceef4] px-4 py-3 text-left last:border-b-0 hover:bg-[#fafbfd]"
onClick={() => {
setSelectedInviteUser(candidate);
setInviteResults([]);
}}
>
<Avatar className="h-9 w-9">
<AvatarFallback>{candidate.displayName.slice(0, 1)}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="truncate font-medium">{candidate.displayName}</p>
<p className="truncate text-sm text-[#667085]">
{candidate.email ?? candidate.phone ?? candidate.username ?? 'Без контактов'}
</p>
</div>
</button>
))
) : (
<p className="px-4 py-3 text-sm text-[#667085]">Пользователи не найдены</p>
)}
</div>
</>
)}
<Button className="mt-4 w-full rounded-xl" disabled={!selectedInviteUser} onClick={() => void submitInvite()}>
Отправить приглашение
</Button>
</DialogContent>
</Dialog>
<Dialog open={createRoomOpen} onOpenChange={setCreateRoomOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
<DialogHeader><DialogTitle>Новый групповой чат</DialogTitle></DialogHeader>
<Input placeholder="Название чата" value={newRoomName} onChange={(event) => setNewRoomName(event.target.value)} />
<div className="mt-3 space-y-2">
<p className="text-sm font-medium">Участники</p>
{(group?.members ?? []).filter((member) => member.userId !== user?.id).map((member) => (
<label key={member.id} className="flex items-center gap-2 rounded-xl bg-[#f4f5f8] px-3 py-2 text-sm">
<input
type="checkbox"
checked={selectedMembers.includes(member.userId)}
onChange={(event) => {
setSelectedMembers((current) =>
event.target.checked ? [...current, member.userId] : current.filter((id) => id !== member.userId)
);
}}
/>
{member.displayName}
</label>
))}
</div>
<Button className="mt-4 w-full rounded-xl" onClick={() => void submitCreateRoom()}>Создать чат</Button>
</DialogContent>
</Dialog>
<Dialog open={pollOpen} onOpenChange={setPollOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
<DialogHeader><DialogTitle>Создать опрос</DialogTitle></DialogHeader>
<Input placeholder="Вопрос" value={pollQuestion} onChange={(event) => setPollQuestion(event.target.value)} />
<div className="mt-3 space-y-2">
{pollOptions.map((option, index) => (
<Input
key={index}
placeholder={`Вариант ${index + 1}`}
value={option}
onChange={(event) => setPollOptions((current) => current.map((item, i) => (i === index ? event.target.value : item)))}
/>
))}
</div>
<Button variant="secondary" className="mt-2 rounded-xl" onClick={() => setPollOptions((current) => [...current, ''])}>
Добавить вариант
</Button>
<Button className="mt-4 w-full rounded-xl" onClick={() => void submitPoll()}>Отправить опрос</Button>
</DialogContent>
</Dialog>
</div>
);
}