Files
IdP/apps/frontend/components/family/family-group-view.tsx
2026-06-25 16:01:41 +03:00

1399 lines
60 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,
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<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>
);
}
function ChatRoomAvatar({ roomId, name, hasAvatar, token }: { roomId: string; name: string; hasAvatar?: boolean; token: string | null }) {
const [url, setUrl] = useState<string | null>(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 (
<Avatar className="h-11 w-11">
{url ? <AvatarImage src={url} alt={name} /> : null}
<AvatarFallback>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
);
}
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<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 [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<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 [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState('');
const [presenceMembers, setPresenceMembers] = useState<FamilyPresenceMember[]>([]);
const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false);
const [deletingFamily, setDeletingFamily] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const readReceiptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(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 roomAvatarInputRef = 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 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<PresignedUploadResponse>(
`/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<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 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<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>
<button
type="button"
onClick={() => setFamilyMembersOpen(true)}
className="mt-3 flex w-full items-center gap-3 rounded-2xl bg-[#f4f5f8] px-3 py-3 text-left transition hover:bg-[#eceef4]"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#3390ec]/10 text-[#3390ec]">
<Users className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">Участники семьи</p>
<p className="text-xs text-[#667085]">
{onlineCount > 0 ? `${onlineCount} в сети · ` : ''}
{group?.members?.length ?? 0} человек
</p>
</div>
</button>
</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'
)}
>
<ChatRoomAvatar roomId={room.id} name={room.name} hasAvatar={room.hasAvatar} token={token} />
<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]">
{messagePreview(room.lastMessage) || (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">
<button type="button" className="flex min-w-0 items-center gap-3 text-left" onClick={() => setMembersOpen(true)}>
<div className="shrink-0 overflow-hidden rounded-full">
<ChatRoomAvatar roomId={activeRoom.id} name={activeRoom.name} hasAvatar={activeRoom.hasAvatar} token={token} />
</div>
<div className="min-w-0">
<h2 className="font-semibold">{activeRoom.name}</h2>
<p className="text-xs text-[#667085]">
{onlineCount > 0 ? (
<span className="text-emerald-600">{onlineCount} в сети</span>
) : (
'Никого нет в сети'
)}
{' · '}
{activeRoom.members.length} участников
</p>
</div>
</button>
<div className="flex items-center gap-2">
{activeRoom.type === 'GROUP' ? (
<Button size="sm" variant="secondary" className="rounded-xl" onClick={() => roomAvatarInputRef.current?.click()}>
<Camera className="h-4 w-4" />
</Button>
) : null}
<Button size="sm" variant="secondary" className="rounded-xl" onClick={() => setMembersOpen(true)}>
<Users className="h-4 w-4" />
</Button>
<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>
<div className="flex-1 space-y-2 overflow-y-auto px-4 py-4">
{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 (
<div key={message.id} className={cn('group flex gap-2', mine ? 'justify-end' : 'justify-start')}>
{!mine ? (
<UserAvatar
userId={message.senderId}
displayName={message.senderName}
hasAvatar={message.senderHasAvatar}
token={token}
isVerified={message.senderIsVerified}
verificationIcon={message.senderVerificationIcon}
className="h-8 w-8 self-end"
badgeSize="xs"
/>
) : null}
<div
className={cn(
'relative 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]',
message.isDeleted && 'bg-[#eef1f6]/90 text-[#667085] italic'
)}
>
{canEdit ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'absolute -right-2 -top-2 flex h-7 w-7 items-center justify-center rounded-full bg-white text-[#667085] opacity-0 shadow-sm transition group-hover:opacity-100',
mine ? '-left-2 right-auto' : ''
)}
aria-label="Действия с сообщением"
>
<MoreVertical className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={mine ? 'end' : 'start'} className="rounded-xl">
<DropdownMenuItem onClick={() => void startEditMessage(message)}>
<Pencil className="mr-2 h-4 w-4" />
Изменить
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600 focus:text-red-600"
onClick={() => void handleDeleteMessage(message.id)}
>
<Trash2 className="mr-2 h-4 w-4" />
Удалить
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{!mine ? <p className="mb-1 text-xs font-semibold text-[#3390ec]">{message.senderName}</p> : null}
{message.isDeleted ? (
<p className="text-[15px] italic text-[#8b93a7]">Сообщение удалено</p>
) : isEditing ? (
<div className="space-y-2">
<textarea
value={editDraft}
onChange={(event) => setEditDraft(event.target.value)}
rows={2}
className="w-full resize-none rounded-xl border border-[#dce3ec] bg-white px-3 py-2 text-[15px] outline-none focus:border-[#3390ec]"
/>
<div className="flex justify-end gap-2">
<Button size="sm" variant="secondary" className="h-8 rounded-xl" onClick={() => {
setEditingMessageId(null);
setEditDraft('');
}}>
Отмена
</Button>
<Button size="sm" className="h-8 rounded-xl" disabled={sending} onClick={() => void submitEditMessage()}>
Сохранить
</Button>
</div>
</div>
) : 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') {
const metaDuration = parseMessageMetadata(message.metadataJson).durationMs;
return (
<VoiceMessagePlayer
src={media.blobUrl}
seed={message.storageKey ?? message.id}
durationMs={typeof metaDuration === 'number' ? metaDuration : undefined}
variant={mine ? 'mine' : 'theirs'}
/>
);
}
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>
)}
<div className={cn('mt-1 flex items-center justify-end gap-1 text-[11px]', mine ? 'text-[#7ea06a]' : 'text-[#a8adbc]')}>
{message.editedAt ? <span className="mr-1">изменено</span> : null}
<span>{formatMessageTime(message.createdAt)}</span>
{mine && !message.isDeleted ? (
message.readByAll ? (
<CheckCheck className="h-3.5 w-3.5 text-[#3390ec]" aria-label="Прочитано" />
) : (
<Check className="h-3.5 w-3.5" aria-label="Отправлено" />
)
) : null}
</div>
</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={roomAvatarInputRef} type="file" accept="image/*" className="hidden" onChange={(event) => {
const file = event.target.files?.[0];
if (file) void uploadRoomAvatar(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 = '';
}} />
<FamilyInviteDialog
groupId={groupId}
open={inviteOpen}
onOpenChange={setInviteOpen}
onInvited={() => void loadGroup()}
/>
<Dialog open={familyMembersOpen} onOpenChange={setFamilyMembersOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Участники семьи</DialogTitle>
</DialogHeader>
<p className="text-sm text-[#667085]">
{group?.name} · {group?.members?.length ?? 0} {group?.members?.length === 1 ? 'участник' : 'участников'}
</p>
<div className="mt-3 max-h-[420px] space-y-2 overflow-y-auto">
{(group?.members ?? []).map((member) => {
const isSelf = member.userId === user?.id;
const canRemove =
member.role !== 'owner' && (isFamilyOwner || isSelf);
const presence = presenceByUserId.get(member.userId);
return (
<div key={member.id} className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-3 py-3">
<AvatarWithPresence
userId={member.userId}
displayName={member.displayName}
hasAvatar={member.hasAvatar}
token={token}
isVerified={member.isVerified}
verificationIcon={member.verificationIcon}
online={isSelf ? undefined : presence?.online}
className="h-10 w-10"
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">
{member.displayName}
{isSelf ? <span className="ml-1 text-xs font-normal text-[#667085]">(Вы)</span> : null}
</p>
<p className="text-xs text-[#667085]">{familyMemberRoleLabel(member.role)}</p>
{!isSelf ? (
<p className={cn('text-xs', presence?.online ? 'font-medium text-emerald-600' : 'text-[#a8adbc]')}>
{presence ? formatPresenceStatus(presence) : 'Статус неизвестен'}
</p>
) : null}
</div>
{canRemove ? (
<Button
size="sm"
variant="ghost"
className="h-8 shrink-0 rounded-xl text-xs text-red-600 hover:text-red-700"
onClick={() => void handleRemoveFromFamily(member.id)}
>
{isSelf ? 'Выйти' : 'Удалить'}
</Button>
) : null}
</div>
);
})}
</div>
<Button className="mt-4 w-full rounded-xl" variant="secondary" onClick={() => {
setFamilyMembersOpen(false);
setInviteOpen(true);
}}>
<UserPlus className="mr-2 h-4 w-4" />
Пригласить в семью
</Button>
{isFamilyOwner ? (
<Button
className="mt-2 w-full rounded-xl bg-red-600 hover:bg-red-700"
onClick={() => {
setFamilyMembersOpen(false);
setDeleteFamilyDialogOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Удалить семью
</Button>
) : null}
</DialogContent>
</Dialog>
<Dialog open={deleteFamilyDialogOpen} onOpenChange={setDeleteFamilyDialogOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>Удалить семью «{group?.name}»?</DialogTitle>
</DialogHeader>
<p className="text-sm leading-relaxed text-[#667085]">
Все участники будут исключены из семьи, все чаты и сообщения будут удалены без возможности
восстановления. Это действие нельзя отменить.
</p>
<div className="mt-6 flex gap-3">
<Button variant="secondary" className="flex-1" disabled={deletingFamily} onClick={() => setDeleteFamilyDialogOpen(false)}>
Отмена
</Button>
<Button className="flex-1 bg-red-600 hover:bg-red-700" disabled={deletingFamily} onClick={() => void confirmDeleteFamily()}>
{deletingFamily ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Удаляем...
</>
) : (
'Удалить семью'
)}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={membersOpen} onOpenChange={setMembersOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
<DialogHeader><DialogTitle>Участники чата</DialogTitle></DialogHeader>
<div className="max-h-[420px] space-y-2 overflow-y-auto">
{activeRoom?.members.map((member) => {
const familyMember = group?.members?.find((item) => item.userId === member.userId);
const presence = presenceByUserId.get(member.userId);
const canRemoveFromChat =
canManageChatMembers &&
activeRoom.type === 'GROUP' &&
member.userId !== user?.id &&
!(member.isChatCreator && !isFamilyOwner);
const canRemoveFromFamily = isFamilyOwner && member.familyRole !== 'owner' && familyMember;
return (
<div key={member.id} className="flex items-center gap-3 rounded-2xl bg-[#f4f5f8] px-3 py-3">
<AvatarWithPresence
userId={member.userId}
displayName={member.displayName}
hasAvatar={member.hasAvatar}
token={token}
isVerified={member.isVerified}
verificationIcon={member.verificationIcon}
online={presence?.online}
className="h-10 w-10"
/>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{member.displayName}</p>
<p className="text-xs text-[#667085]">{memberRoleLabel(member)}</p>
<p className={cn('text-xs', presence?.online ? 'font-medium text-emerald-600' : 'text-[#a8adbc]')}>
{presence ? formatPresenceStatus(presence) : 'Статус неизвестен'}
</p>
</div>
<div className="flex shrink-0 flex-col gap-1">
{canRemoveFromChat ? (
<Button size="sm" variant="secondary" className="h-8 rounded-xl text-xs" onClick={() => void handleRemoveFromChat(member.userId)}>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Из чата
</Button>
) : null}
{canRemoveFromFamily ? (
<Button size="sm" variant="ghost" className="h-8 rounded-xl text-xs text-red-600 hover:text-red-700" onClick={() => void handleRemoveFromFamily(familyMember!.id)}>
Из семьи
</Button>
) : null}
</div>
</div>
);
})}
</div>
{activeRoom?.type === 'GROUP' && familyMembersNotInRoom.length > 0 ? (
<Button className="mt-4 w-full rounded-xl" onClick={() => {
setMembersOpen(false);
setAddMembersOpen(true);
}}>
<UserPlus className="mr-2 h-4 w-4" />
Добавить участника
</Button>
) : null}
</DialogContent>
</Dialog>
<Dialog open={addMembersOpen} onOpenChange={setAddMembersOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[420px]">
<DialogHeader><DialogTitle>Добавить в чат</DialogTitle></DialogHeader>
<div className="space-y-2">
{familyMembersNotInRoom.length ? (
familyMembersNotInRoom.map((member) => {
const presence = presenceByUserId.get(member.userId);
return (
<button
key={member.id}
type="button"
className="flex w-full items-center gap-3 rounded-2xl bg-[#f4f5f8] px-3 py-3 text-left transition hover:bg-[#eceef4]"
onClick={() => void handleAddRoomMember(member.userId)}
>
<AvatarWithPresence
userId={member.userId}
displayName={member.displayName}
hasAvatar={member.hasAvatar}
token={token}
isVerified={member.isVerified}
verificationIcon={member.verificationIcon}
online={presence?.online}
className="h-9 w-9"
/>
<div>
<p className="font-medium">{member.displayName}</p>
<p className="text-xs text-[#667085]">{member.role === 'owner' ? 'Создатель семьи' : 'Участник семьи'}</p>
</div>
</button>
);
})
) : (
<p className="py-6 text-center text-sm text-[#667085]">Все участники семьи уже в этом чате</p>
)}
</div>
</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>
);
}