Files
IdP/apps/frontend/components/family/family-group-view.tsx
2026-06-26 00:16:17 +03:00

2136 lines
88 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
CalendarDays,
Camera,
Check,
CheckCheck,
FileText,
ImageIcon,
Loader2,
Mic,
Paperclip,
Plus,
Search,
Send,
Settings,
Smile,
Trash2,
UserPlus,
Users,
Volume2,
VolumeX,
X
} from 'lucide-react';
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { FamilyInviteDialog } from '@/components/family/family-invite-dialog';
import { HoverUploadAvatar } from '@/components/family/hover-upload-avatar';
import { BotMiniAppSheet, FamilyBotChat } from '@/components/family/family-bot-chat';
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
import { ChatForwardDialog } from '@/components/chat/chat-forward-dialog';
import { ChatDateSeparator } from '@/components/chat/chat-date-separator';
import { ChatToolsDialog, type ChatToolsTab } from '@/components/chat/chat-tools-dialog';
import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner';
import { ChatMessageContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
import { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
import { ChatSelectionToolbar } from '@/components/chat/chat-selection-toolbar';
import { EmojiPicker } from '@/components/chat/emoji-picker';
import { InlineEditableTitle } from '@/components/chat/inline-editable-title';
import { RepliedMessagePreview, scrollToChatMessage } from '@/components/chat/replied-message-preview';
import { TypingIndicator } from '@/components/chat/typing-indicator';
import { VoiceMessagePlayer } from '@/components/chat/voice-message-player';
import { UserAvatar } from '@/components/id/user-avatar';
import { useAuth } from '@/components/id/auth-provider';
import { useToast } from '@/components/id/toast-provider';
import { useRealtime } from '@/components/notifications/realtime-provider';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { useRequireAuth } from '@/hooks/use-require-auth';
import {
addChatRoomMember,
apiFetch,
ChatMessage,
ChatRoom,
createChatRoom,
createE2ERoom,
deleteChatMessage,
deleteChatRoom,
deleteFamilyGroup,
editChatMessage,
BotChatMeta,
FamilyGroup,
FamilyPresenceMember,
fetchChatMessages,
fetchChatRooms,
fetchFamilyGroup,
fetchFamilyPresence,
forwardChatMessages,
getApiErrorMessage,
markChatRoomRead,
removeChatRoomMember,
removeFamilyMember,
reportChatTyping,
sendChatMessage,
setChatRoomMuted,
toggleChatMessageReaction,
updateFamilyGroup,
uploadMediaObject,
voteChatPoll
} from '@/lib/api';
import {
decryptE2EBlob,
decryptE2EPayload,
encryptE2EBlob,
e2eKindFromMessageType
} from '@/lib/e2e-chat';
import { getE2EMessageText, useE2EChat } from '@/hooks/use-e2e-chat';
import { useChatMessageSelection } from '@/hooks/use-chat-message-selection';
import { useChatDragSelection } from '@/hooks/use-chat-drag-selection';
import { useChatTyping } from '@/hooks/use-chat-typing';
import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils';
import { AVATAR_UPDATED_EVENT, dispatchChatRoomAvatarUpdated } from '@/lib/avatar-events';
import { getSingleEmojiNative, isSingleEmojiMessage } from '@/lib/chat-emoji-display';
import { findMemberRoom, readStoredActiveRoomId, roomDisplayLabel, storeActiveRoomId } from '@/lib/family-chat';
import {
getChatListPreviewText,
getMessageCopyText,
getMessagePreviewText,
isChatInteractiveTarget,
isSystemMessage,
parseForwardedFrom
} from '@/lib/chat-message-utils';
import { cn } from '@/lib/utils';
import {
createBlobObjectUrl,
fetchAuthenticatedMediaBlob,
mediaExpiresAtMs,
revokeBlobObjectUrl,
shouldRefreshMedia,
triggerBlobDownload
} from '@/lib/authenticated-media';
import {
detectChatMessageType,
fileIconLabel,
formatFileSize,
parseMessageMetadata,
resolveChatContentType
} from '@/lib/chat-media';
interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
expiresAt?: string;
uploadToken?: string;
}
interface LoadedChatMedia {
blobUrl: string;
expiresAt: number;
fileName?: string;
mimeType?: string;
}
function formatMessageTime(value: string) {
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
}
function formatLastSeen(value?: string) {
if (!value) return 'Не в сети';
const date = new Date(value);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60_000);
if (diffMinutes < 1) return 'был(а) только что';
if (diffMinutes < 60) return `был(а) ${diffMinutes} мин. назад`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `был(а) ${diffHours} ч. назад`;
return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }).format(date);
}
function formatPresenceStatus(member: FamilyPresenceMember) {
if (member.online) return 'В сети';
return formatLastSeen(member.lastSeenAt);
}
function FamilySidebarAvatar({
groupId,
name,
hasAvatar,
token,
uploading,
onUpload
}: {
groupId: string;
name: string;
hasAvatar?: boolean;
token: string | null;
uploading?: boolean;
onUpload: (file: File) => void;
}) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
if (!hasAvatar || !token) {
setUrl(null);
return;
}
apiFetch<{ accessUrl: string }>(`/media/families/${groupId}/avatar/url`, {}, token)
.then((response) => setUrl(response.accessUrl))
.catch(() => setUrl(null));
}, [groupId, hasAvatar, token]);
return (
<HoverUploadAvatar
name={name}
imageUrl={url}
uploading={uploading}
onFileSelect={onUpload}
className="h-11 w-11 shrink-0"
/>
);
}
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>(() => readStoredActiveRoomId(groupId));
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 [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 [presenceMembers, setPresenceMembers] = useState<FamilyPresenceMember[]>([]);
const [deleteFamilyDialogOpen, setDeleteFamilyDialogOpen] = useState(false);
const [deletingFamily, setDeletingFamily] = useState(false);
const [deleteMessagesDialogOpen, setDeleteMessagesDialogOpen] = useState(false);
const [pendingDeleteMessageIds, setPendingDeleteMessageIds] = useState<string[]>([]);
const [miniAppUrl, setMiniAppUrl] = useState<string | null>(null);
const [miniAppTitle, setMiniAppTitle] = useState('Mini App');
const [botChatMeta, setBotChatMeta] = useState<BotChatMeta | null>(null);
const [deletingRoomId, setDeletingRoomId] = useState<string | null>(null);
const [familyAvatarUploading, setFamilyAvatarUploading] = useState(false);
const [roomAvatarUploading, setRoomAvatarUploading] = useState(false);
const [forwardDialogOpen, setForwardDialogOpen] = useState(false);
const [forwarding, setForwarding] = useState(false);
const [bulkDeleting, setBulkDeleting] = useState(false);
const [replyToMessage, setReplyToMessage] = useState<ChatMessage | null>(null);
const [chatToolsOpen, setChatToolsOpen] = useState(false);
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
const messagesEndRef = useRef<HTMLDivElement>(null);
const readReceiptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const typingEmitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastTypingSentAtRef = useRef(0);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const recordingStartedAtRef = useRef<number | null>(null);
const mediaUrlsRef = useRef<Record<string, LoadedChatMedia>>({});
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
mediaUrlsRef.current = mediaUrls;
}, [mediaUrls]);
const appendMessage = useCallback((message: ChatMessage) => {
if (message.isDeleted) return;
setMessages((current) => (current.some((item) => item.id === message.id) ? current : [...current, message]));
}, []);
const visibleMessages = useMemo(() => messages.filter((message) => !message.isDeleted), [messages]);
const messageById = useMemo(
() => new Map(visibleMessages.map((message) => [message.id, message])),
[visibleMessages]
);
const { typers, registerTyper, clearTypers } = useChatTyping(user?.id);
const {
selectionMode,
selectedMessageIds,
setSelectedMessageIds,
enterSelectionMode,
exitSelectionMode,
toggleSelectedMessage,
addSelectedMessage,
handleSelectionPointer
} = useChatMessageSelection(visibleMessages);
const { handleMessagePointerDown, handleDragEnterMessage, isDragging } = useChatDragSelection({
selectionMode,
enterSelectionMode,
addSelectedMessage
});
const activeRoom = useMemo(() => rooms.find((room) => room.id === activeRoomId) ?? null, [activeRoomId, rooms]);
const activeRoomIsBot = activeRoom?.type === 'BOT';
useEffect(() => {
if (!activeRoomIsBot) {
setBotChatMeta(null);
}
}, [activeRoomIsBot, activeRoom?.id]);
const activeRoomIsE2E = activeRoom?.type === 'E2E';
const { peerE2eKey, peerKeyLoading, e2ePayloads, e2eErrors, encryptOutgoing } = useE2EChat({
room: activeRoom,
messages,
userId: user?.id,
token
});
const activeRoomTitle = useMemo(
() => (activeRoom && user ? roomDisplayLabel(activeRoom, user.id) : ''),
[activeRoom, user]
);
const myMembership = activeRoom?.members.find((member) => member.userId === user?.id);
const isFamilyOwner = group?.ownerId === user?.id;
const isChatCreator = activeRoom?.createdById === user?.id;
const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator);
const canManageActiveBot = Boolean(
activeRoomIsBot && botChatMeta?.manageWebAppUrl && user?.id && botChatMeta.botOwnerId === user.id
);
const familyMembersNotInRoom = useMemo(() => {
if (!group?.members || !activeRoom) return [];
const inRoom = new Set(activeRoom.members.map((member) => member.userId));
return group.members.filter((member) => !inRoom.has(member.userId));
}, [activeRoom, group?.members]);
const presenceByUserId = useMemo(
() => new Map(presenceMembers.map((member) => [member.userId, member])),
[presenceMembers]
);
const onlineCount = useMemo(() => presenceMembers.filter((member) => member.online).length, [presenceMembers]);
const loadGroup = useCallback(async () => {
if (!token || isPinLocked) return;
const [groupResponse, roomsResponse] = await Promise.all([fetchFamilyGroup(groupId, token), fetchChatRooms(groupId, token)]);
setGroup(groupResponse);
const roomList = roomsResponse.rooms ?? [];
setRooms(roomList);
setActiveRoomId((current) => {
const preferred = current ?? readStoredActiveRoomId(groupId);
if (preferred && roomList.some((room) => room.id === preferred)) {
return preferred;
}
return roomList[0]?.id ?? null;
});
}, [groupId, isPinLocked, token]);
useEffect(() => {
storeActiveRoomId(groupId, activeRoomId);
}, [activeRoomId, groupId]);
useEffect(() => {
exitSelectionMode();
setReplyToMessage(null);
cancelEditMessage();
clearTypers();
}, [activeRoomId, clearTypers, exitSelectionMode]);
useEffect(() => {
setActiveRoomId(readStoredActiveRoomId(groupId));
}, [groupId]);
const loadMessages = useCallback(async (roomId: string) => {
if (!token || isPinLocked) return;
const response = await fetchChatMessages(roomId, token);
setMessages((response.messages ?? []).filter((message) => !message.isDeleted));
}, [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(() => {
function handleAvatarUpdated() {
void loadGroup();
}
window.addEventListener(AVATAR_UPDATED_EVENT, handleAvatarUpdated);
return () => window.removeEventListener(AVATAR_UPDATED_EVENT, handleAvatarUpdated);
}, [loadGroup]);
useEffect(() => {
if (!token || isPinLocked) return;
void loadPresence();
const timer = window.setInterval(() => void loadPresence(), 25_000);
return () => window.clearInterval(timer);
}, [connected, isPinLocked, loadPresence, token]);
useEffect(() => {
if (!activeRoomId || activeRoomIsBot) return;
void loadMessages(activeRoomId);
setEditingMessageId(null);
setDraft('');
}, [activeRoomId, activeRoomIsBot, loadMessages]);
useEffect(() => {
if (!activeRoomId || !token || !messages.length) return;
const timer = window.setTimeout(() => {
const last = messages[messages.length - 1];
void markChatRoomRead(activeRoomId, last.id, token).catch(() => {});
}, 500);
return () => window.clearTimeout(timer);
}, [activeRoomId, messages, token]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
if (!activeRoomId || !token || activeRoomIsBot) return;
if (!draft.trim()) return;
const emitTyping = () => {
lastTypingSentAtRef.current = Date.now();
void reportChatTyping(activeRoomId, token).catch(() => {});
};
const elapsed = Date.now() - lastTypingSentAtRef.current;
if (elapsed >= 2000) {
emitTyping();
} else if (typingEmitTimerRef.current) {
clearTimeout(typingEmitTimerRef.current);
typingEmitTimerRef.current = setTimeout(emitTyping, 2000 - elapsed);
} else {
typingEmitTimerRef.current = setTimeout(emitTyping, 2000 - elapsed);
}
return () => {
if (typingEmitTimerRef.current) {
clearTimeout(typingEmitTimerRef.current);
typingEmitTimerRef.current = null;
}
};
}, [activeRoomId, activeRoomIsBot, draft, token]);
useEffect(() => {
return subscribe((event) => {
const payload = event.payload;
if (event.type === 'bot_message' || event.type === 'bot_message_edited') {
if (activeRoomIsBot) {
void loadGroup();
}
return;
}
if (payload?.roomId && payload.roomId !== activeRoomId) {
if (event.type === 'chat_message') void loadGroup();
return;
}
if (event.type === 'chat_typing' && payload?.roomId === activeRoomId) {
const typerId = typeof payload.userId === 'string' ? payload.userId : '';
const typerName = typeof payload.userName === 'string' ? payload.userName : 'Участник';
if (typerId) registerTyper(typerId, typerName);
return;
}
if (event.type === 'chat_message') {
if (payload?.message) {
appendMessage(payload.message as ChatMessage);
} else if (activeRoomId) {
void loadMessages(activeRoomId);
}
void loadGroup();
return;
}
if (event.type === 'chat_message_updated') {
const updated = payload?.message as ChatMessage | undefined;
if (updated) {
if (updated.isDeleted) {
setMessages((current) => current.filter((item) => item.id !== updated.id));
setSelectedMessageIds((current) => current.filter((id) => id !== updated.id));
} else {
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
}
} else if (activeRoomId) {
void loadMessages(activeRoomId);
}
void loadGroup();
return;
}
if (event.type === 'chat_message_deleted') {
const deleted = payload?.message as ChatMessage | undefined;
if (deleted?.id) {
setMessages((current) => current.filter((item) => item.id !== deleted.id));
setSelectedMessageIds((current) => current.filter((id) => id !== deleted.id));
if (editingMessageId === deleted.id) {
cancelEditMessage();
}
} else if (activeRoomId) {
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,
activeRoomIsBot,
appendMessage,
editingMessageId,
groupId,
loadGroup,
loadMessages,
registerTyper,
router,
setSelectedMessageIds,
showToast,
subscribe
]);
const loadChatMedia = useCallback(
async (message: ChatMessage, force = false) => {
if (!token || !activeRoomId || !message.storageKey) return;
const storageKey = message.storageKey;
const existing = mediaUrlsRef.current[storageKey];
if (!force && existing && !shouldRefreshMedia(existing.expiresAt)) return;
let meta = parseMessageMetadata(message.metadataJson);
let fileName = meta.fileName || message.content || undefined;
let mimeType = message.mimeType || (message.type === 'VOICE' ? 'audio/webm' : message.type === 'AUDIO' ? 'audio/mpeg' : undefined);
const query = new URLSearchParams({ storageKey });
if (fileName) query.set('fileName', fileName);
try {
const access = await apiFetch<{ accessUrl: string; expiresAt: string }>(
`/media/chat/${activeRoomId}/media/url?${query.toString()}`,
{},
token
);
let blob = await fetchAuthenticatedMediaBlob(access.accessUrl, token, mimeType);
if (message.isEncrypted && activeRoomIsE2E && peerE2eKey && user?.id && message.content) {
const payload = await decryptE2EPayload(user.id, peerE2eKey, message.content);
const decryptedBuffer = await decryptE2EBlob(user.id, peerE2eKey, await blob.arrayBuffer());
mimeType = payload?.mimeType || mimeType;
fileName = payload?.fileName || fileName;
blob = new Blob([decryptedBuffer], { type: mimeType || 'application/octet-stream' });
}
const blobUrl = createBlobObjectUrl(blob);
setMediaUrls((current) => {
revokeBlobObjectUrl(current[storageKey]?.blobUrl);
return {
...current,
[storageKey]: {
blobUrl,
expiresAt: mediaExpiresAtMs(access.expiresAt),
fileName,
mimeType
}
};
});
} catch {
// ignore failed media loads in background
}
},
[activeRoomId, activeRoomIsE2E, peerE2eKey, token, user?.id]
);
useEffect(() => {
if (!token || !activeRoomId) return;
messages.forEach((message) => {
if (message.storageKey) void loadChatMedia(message);
});
}, [activeRoomId, loadChatMedia, messages, token]);
useEffect(() => {
if (!token || !activeRoomId) return;
const timer = window.setInterval(() => {
messages.forEach((message) => {
if (!message.storageKey) return;
const cached = mediaUrlsRef.current[message.storageKey];
if (cached && shouldRefreshMedia(cached.expiresAt)) {
void loadChatMedia(message, true);
}
});
}, 60_000);
return () => window.clearInterval(timer);
}, [activeRoomId, loadChatMedia, messages, token]);
useEffect(() => {
return () => {
Object.values(mediaUrlsRef.current).forEach((entry) => revokeBlobObjectUrl(entry.blobUrl));
};
}, []);
async function downloadChatFile(message: ChatMessage) {
if (!token || !activeRoomId || !message.storageKey) return;
const storageKey = message.storageKey;
const meta = parseMessageMetadata(message.metadataJson);
const fileName = meta.fileName || message.content || 'file';
setDownloadingKey(storageKey);
try {
let blob: Blob | null = null;
const cached = mediaUrls[storageKey];
if (cached?.blobUrl) {
blob = await fetch(cached.blobUrl).then((response) => response.blob());
} else {
const query = new URLSearchParams({ storageKey });
if (fileName) query.set('fileName', fileName);
const access = await apiFetch<{ accessUrl: string }>(
`/media/chat/${activeRoomId}/media/url?${query.toString()}`,
{},
token
);
blob = await fetchAuthenticatedMediaBlob(`${access.accessUrl}?download=1`, token);
}
if (!blob) {
throw new Error('Не удалось получить файл');
}
triggerBlobDownload(blob, fileName);
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось скачать файл') ?? 'Ошибка');
} finally {
setDownloadingKey(null);
}
}
async function uploadRoomAvatar(file: File) {
if (!token || !activeRoomId || roomAvatarUploading) return;
setRoomAvatarUploading(true);
try {
const presigned = await apiFetch<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();
dispatchChatRoomAvatarUpdated(activeRoomId);
showToast('Фото чата обновлено');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось загрузить фото чата') ?? 'Ошибка');
} finally {
setRoomAvatarUploading(false);
}
}
function applyRoomUpdate(room: ChatRoom) {
setRooms((current) => current.map((item) => (item.id === room.id ? room : item)));
}
async function handleAddRoomMember(memberUserId: string) {
if (!token || !activeRoomId) return;
try {
const room = await addChatRoomMember(activeRoomId, memberUserId, token);
applyRoomUpdate(room);
setAddMembersOpen(false);
showToast('Участник добавлен в чат');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось добавить участника') ?? 'Ошибка');
}
}
async function handleRemoveFromChat(memberUserId: string) {
if (!token || !activeRoomId) return;
try {
const room = await removeChatRoomMember(activeRoomId, memberUserId, token);
applyRoomUpdate(room);
if (memberUserId === user?.id) {
setActiveRoomId(null);
setMembersOpen(false);
}
showToast('Участник удалён из чата');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось удалить участника из чата') ?? 'Ошибка');
}
}
async function handleRemoveFromFamily(memberId: string) {
if (!token) return;
try {
await removeFamilyMember(memberId, token);
const removedMember = group?.members?.find((item) => item.id === memberId);
if (removedMember?.userId === user?.id) {
showToast('Вы вышли из семьи');
router.push('/family');
return;
}
await loadGroup();
setMembersOpen(false);
setFamilyMembersOpen(false);
showToast('Участник удалён из семьи');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось удалить участника из семьи') ?? 'Ошибка');
}
}
async function createSecretChatWithMember(memberUserId: string) {
if (!token) return;
try {
const room = await createE2ERoom(groupId, memberUserId, token);
await loadGroup();
setActiveRoomId(room.id);
setFamilyMembersOpen(false);
showToast('Секретный E2E чат создан');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось создать секретный чат') ?? 'Ошибка');
}
}
async function openMemberChatFromList(memberUserId: string, options?: { isBot?: boolean; e2e?: boolean }) {
if (!user || !token) return;
let room = findMemberRoom(rooms, memberUserId, user.id, options);
if (!room) {
const roomsResponse = await fetchChatRooms(groupId, token);
const roomList = roomsResponse.rooms ?? [];
setRooms(roomList);
room = findMemberRoom(roomList, memberUserId, user.id, options);
}
if (room) {
setActiveRoomId(room.id);
setFamilyMembersOpen(false);
} else {
showToast('Чат ещё создаётся. Обновите страницу через несколько секунд.');
}
}
async function confirmDeleteFamily() {
if (!token) return;
setDeletingFamily(true);
try {
await deleteFamilyGroup(groupId, token);
setDeleteFamilyDialogOpen(false);
setFamilyMembersOpen(false);
showToast('Семья удалена');
router.push('/family');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось удалить семью') ?? 'Ошибка');
} finally {
setDeletingFamily(false);
}
}
async function saveFamilyName(nextName: string) {
if (!token || !nextName.trim()) return;
try {
const updated = await updateFamilyGroup(groupId, nextName.trim(), token);
setGroup(updated);
showToast('Название семьи обновлено');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось переименовать семью') ?? 'Ошибка');
throw error;
}
}
async function uploadFamilyAvatar(file: File) {
if (!token || familyAvatarUploading) return;
setFamilyAvatarUploading(true);
try {
const presigned = await apiFetch<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, 'Не удалось загрузить аватар') ?? 'Ошибка');
} finally {
setFamilyAvatarUploading(false);
}
}
async function submitCreateRoom() {
if (!token || !newRoomName.trim()) return;
try {
const room = await createChatRoom(groupId, newRoomName.trim(), selectedMembers, token);
setRooms((current) => [...current, room]);
setActiveRoomId(room.id);
setCreateRoomOpen(false);
setNewRoomName('');
setSelectedMembers([]);
showToast('Групповой чат создан');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось создать чат') ?? 'Ошибка');
}
}
async function startEditMessage(message: ChatMessage) {
if (message.isDeleted) return;
exitSelectionMode();
setEditingMessageId(message.id);
setDraft(message.content ?? '');
setReplyToMessage(null);
setEmojiOpen(false);
}
function cancelEditMessage() {
setEditingMessageId(null);
setDraft('');
}
async function submitEditMessage() {
if (!token || !editingMessageId) return;
const content = draft.trim();
if (!content) {
showToast('Сообщение не может быть пустым');
return;
}
setSending(true);
try {
const updated = await editChatMessage(editingMessageId, content, token);
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
cancelEditMessage();
void loadGroup();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось изменить сообщение') ?? 'Ошибка');
} finally {
setSending(false);
}
}
async function handleDeleteRoom(roomId: string) {
if (!token) return;
const room = rooms.find((item) => item.id === roomId);
if (!room || room.type === 'GENERAL') return;
if (!window.confirm('Удалить этот чат? Все сообщения будут удалены без возможности восстановления.')) return;
setDeletingRoomId(roomId);
try {
await deleteChatRoom(roomId, token);
if (activeRoomId === roomId) {
setActiveRoomId(null);
setMessages([]);
}
await loadGroup();
showToast('Чат удалён');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось удалить чат') ?? 'Ошибка');
} finally {
setDeletingRoomId(null);
}
}
function requestDeleteMessages(messageIds: string[]) {
if (!messageIds.length) return;
setPendingDeleteMessageIds(messageIds);
setDeleteMessagesDialogOpen(true);
}
async function confirmDeleteMessages() {
if (!token || !pendingDeleteMessageIds.length) return;
const ids = [...pendingDeleteMessageIds];
setBulkDeleting(true);
setMessages((current) => current.filter((item) => !ids.includes(item.id)));
try {
for (const messageId of ids) {
await deleteChatMessage(messageId, token);
if (editingMessageId === messageId) {
cancelEditMessage();
}
}
setSelectedMessageIds((current) => current.filter((id) => !ids.includes(id)));
exitSelectionMode();
setDeleteMessagesDialogOpen(false);
setPendingDeleteMessageIds([]);
void loadGroup();
showToast(ids.length === 1 ? 'Сообщение удалено' : 'Сообщения удалены');
} catch (error) {
if (activeRoomId) void loadMessages(activeRoomId);
showToast(getApiErrorMessage(error, 'Не удалось удалить сообщение') ?? 'Ошибка');
} finally {
setBulkDeleting(false);
}
}
async function handleToggleReaction(messageId: string, emoji: string) {
if (!token) return;
try {
const updated = await toggleChatMessageReaction(messageId, emoji, token);
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось поставить реакцию') ?? 'Ошибка');
}
}
function getSelectedMessages() {
const selected = new Set(selectedMessageIds);
return visibleMessages.filter((message) => selected.has(message.id));
}
async function handleBulkCopy() {
const selected = getSelectedMessages();
if (!selected.length) return;
const lines = selected
.map((message) =>
getMessageCopyText(
message,
getE2EMessageText({
message,
isE2E: activeRoomIsE2E,
peerE2eKey,
peerKeyLoading,
e2ePayload: message.isEncrypted ? e2ePayloads[message.id] : null,
e2eError: e2eErrors[message.id]
})
)
)
.filter(Boolean);
if (!lines.length) {
showToast('Нечего копировать');
return;
}
await navigator.clipboard.writeText(lines.join('\n\n'));
showToast('Сообщения скопированы');
exitSelectionMode();
}
async function handleBulkDelete() {
const selected = getSelectedMessages().filter((message) => message.senderId === user?.id);
if (!selected.length) {
showToast('Можно удалять только свои сообщения');
return;
}
requestDeleteMessages(selected.map((message) => message.id));
}
async function handleForwardToRoom(targetRoomId: string) {
if (!token) return;
const ids = selectionMode ? selectedMessageIds : [];
if (!ids.length) return;
setForwarding(true);
try {
const response = await forwardChatMessages(targetRoomId, ids, token);
showToast(`Переслано сообщений: ${response.messages?.length ?? ids.length}`);
setForwardDialogOpen(false);
exitSelectionMode();
if (targetRoomId === activeRoomId) {
for (const message of response.messages ?? []) {
appendMessage(message);
}
}
void loadGroup();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось переслать сообщения') ?? 'Ошибка');
} finally {
setForwarding(false);
}
}
function insertEmojiToDraft(nativeEmoji: string) {
setDraft((current) => `${current}${nativeEmoji}`);
}
async function sendText(type: string = 'TEXT') {
if (!token || !activeRoomId || !activeRoom || sending) return;
const content = draft.trim();
if (!content && type === 'TEXT') return;
if (editingMessageId && type === 'TEXT') {
await submitEditMessage();
return;
}
setSending(true);
try {
let payload = content;
let isEncrypted = false;
if (activeRoomIsE2E) {
payload = await encryptOutgoing({ kind: type === 'EMOJI' ? 'emoji' : 'text', text: content, emojiId: type === 'EMOJI' ? content : undefined });
isEncrypted = true;
}
const message = await sendChatMessage(
activeRoomId,
{ type, content: payload, isEncrypted, replyToId: replyToMessage?.id },
token
);
appendMessage(message);
setDraft('');
setReplyToMessage(null);
setEmojiOpen(false);
void loadGroup();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить сообщение') ?? 'Ошибка');
} finally {
setSending(false);
}
}
async function uploadChatFile(file: File, options?: { voice?: boolean; durationMs?: number }) {
if (!token || !activeRoomId || !activeRoom) return;
setSending(true);
try {
const contentType = resolveChatContentType(file);
const messageType = detectChatMessageType(file, { voice: options?.voice });
const metadata = {
fileName: file.name,
fileSize: file.size,
...(options?.durationMs ? { durationMs: options.durationMs } : {})
};
let uploadFile = file;
let uploadContentType = contentType;
let messageContent: string | undefined =
messageType === 'VOICE' ? 'Голосовое сообщение' : messageType === 'FILE' ? file.name : undefined;
let isEncrypted = false;
if (activeRoomIsE2E) {
if (!user?.id || !peerE2eKey) {
showToast('Секретный чат недоступен без ключей E2E у обоих участников');
return;
}
const encryptedBuffer = await encryptE2EBlob(user.id, peerE2eKey, await file.arrayBuffer());
uploadFile = new File([encryptedBuffer], `${file.name}.lendryenc`, { type: 'application/octet-stream' });
uploadContentType = 'application/octet-stream';
messageContent = await encryptOutgoing({
kind: e2eKindFromMessageType(messageType),
fileName: file.name,
mimeType: contentType,
fileSize: file.size,
durationMs: options?.durationMs
});
isEncrypted = true;
}
const presigned = await apiFetch<PresignedUploadResponse>(
`/media/chat/${activeRoomId}/media/upload-url`,
{
method: 'POST',
body: JSON.stringify({ contentType: uploadContentType, fileName: uploadFile.name })
},
token
);
await uploadMediaObject(presigned, uploadFile, uploadContentType);
const message = await sendChatMessage(
activeRoomId,
{
type: messageType,
storageKey: presigned.storageKey,
mimeType: uploadContentType,
content: messageContent,
isEncrypted,
metadataJson: JSON.stringify(activeRoomIsE2E ? { e2e: true, ...metadata } : metadata)
},
token
);
appendMessage(message);
void loadGroup();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить файл') ?? 'Ошибка');
} finally {
setSending(false);
}
}
async function submitPoll() {
if (!token || !activeRoomId) return;
const options = pollOptions.map((item) => item.trim()).filter(Boolean);
if (!pollQuestion.trim() || options.length < 2) {
showToast('Укажите вопрос и минимум 2 варианта');
return;
}
setSending(true);
try {
const message = await sendChatMessage(
activeRoomId,
{ type: 'POLL', poll: { question: pollQuestion.trim(), options } },
token
);
appendMessage(message);
setPollOpen(false);
setPollQuestion('');
setPollOptions(['', '']);
void loadGroup();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось создать опрос') ?? 'Ошибка');
} finally {
setSending(false);
}
}
async function toggleMute() {
if (!token || !activeRoomId || !activeRoom) return;
const muted = !myMembership?.notificationsMuted;
await setChatRoomMuted(activeRoomId, muted, token);
setRooms((current) =>
current.map((room) =>
room.id === activeRoomId
? {
...room,
members: room.members.map((member) =>
member.userId === user?.id ? { ...member, notificationsMuted: muted } : member
)
}
: room
)
);
showToast(muted ? 'Уведомления чата выключены' : 'Уведомления чата включены');
}
async function startVoiceRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const preferredTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg'];
const mimeType = preferredTypes.find((type) => MediaRecorder.isTypeSupported(type)) ?? '';
const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
const chunks: BlobPart[] = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) chunks.push(event.data);
};
recorder.onstop = () => {
stream.getTracks().forEach((track) => track.stop());
const durationMs = recordingStartedAtRef.current ? Date.now() - recordingStartedAtRef.current : undefined;
recordingStartedAtRef.current = null;
const blob = new Blob(chunks, { type: 'audio/webm' });
void uploadChatFile(new File([blob], `voice-${Date.now()}.webm`, { type: 'audio/webm' }), {
voice: true,
durationMs
});
};
mediaRecorderRef.current = recorder;
recordingStartedAtRef.current = Date.now();
recorder.start();
setRecording(true);
} catch {
showToast('Не удалось получить доступ к микрофону');
}
}
function stopVoiceRecording() {
mediaRecorderRef.current?.stop();
mediaRecorderRef.current = null;
setRecording(false);
}
if (!isReady || loading) {
return <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">
<FamilySidebarAvatar
groupId={groupId}
name={group?.name ?? 'С'}
hasAvatar={group?.hasAvatar}
token={token}
uploading={familyAvatarUploading}
onUpload={(file) => void uploadFamilyAvatar(file)}
/>
<div className="min-w-0 flex-1">
<InlineEditableTitle
value={group?.name ?? 'Семья'}
onSave={saveFamilyName}
disabled={!isFamilyOwner}
/>
</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>
</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) => (
<div
key={room.id}
className={cn(
'group flex items-stretch transition hover:bg-[#f7f9fc]',
activeRoomId === room.id && 'bg-[#3390ec]/10'
)}
>
<button
type="button"
onClick={() => setActiveRoomId(room.id)}
className="flex min-w-0 flex-1 items-start gap-3 px-4 py-3 text-left"
>
<ChatRoomAvatarDisplay room={room} viewerUserId={user?.id ?? ''} token={token} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate font-medium">{user ? roomDisplayLabel(room, user.id) : 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]">
{getChatListPreviewText(room.lastMessage) || (room.type === 'GENERAL' ? 'Общий чат семьи' : 'Нет сообщений')}
</p>
</div>
</button>
{room.type !== 'GENERAL' ? (
<button
type="button"
aria-label="Удалить чат"
disabled={deletingRoomId === room.id}
className="mr-2 flex h-8 w-8 shrink-0 self-center items-center justify-center rounded-lg text-[#667085] opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 disabled:opacity-50"
onClick={() => void handleDeleteRoom(room.id)}
>
{deletingRoomId === room.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</button>
) : null}
</div>
))}
</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 className="flex min-w-0 items-center gap-3">
{(activeRoom.type === 'GROUP' || activeRoom.type === 'GENERAL') ? (
<label className="group/room-avatar relative block shrink-0 cursor-pointer overflow-hidden rounded-full">
<ChatRoomAvatarDisplay room={activeRoom} viewerUserId={user?.id ?? ''} token={token} />
<span
className={cn(
'absolute inset-0 flex items-center justify-center bg-black/45 text-white opacity-0 transition-opacity duration-200 group-hover/room-avatar:opacity-100',
roomAvatarUploading && 'opacity-100'
)}
>
{roomAvatarUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Camera className="h-4 w-4" />}
</span>
<input
type="file"
accept="image/*"
className="sr-only"
disabled={roomAvatarUploading}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void uploadRoomAvatar(file);
event.target.value = '';
}}
/>
</label>
) : (
<div className="shrink-0 overflow-hidden rounded-full">
<ChatRoomAvatarDisplay room={activeRoom} viewerUserId={user?.id ?? ''} token={token} />
</div>
)}
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => setMembersOpen(true)}>
<h2 className="font-semibold">{activeRoomTitle}</h2>
<p className="text-xs text-[#667085]">
{activeRoomIsBot ? (
<>🤖 Бот · {activeRoom.members.length} участников</>
) : activeRoomIsE2E ? (
<>🔒 E2E · секретный чат</>
) : activeRoom.type === 'DIRECT' ? (
<>Личный чат · {activeRoom.members.length} участников</>
) : (
<>
{onlineCount > 0 ? (
<span className="text-emerald-600">{onlineCount} в сети</span>
) : (
'Никого нет в сети'
)}
{' · '}
{activeRoom.members.length} участников
</>
)}
</p>
</button>
</div>
<div className="flex items-center gap-2">
{!activeRoomIsBot ? (
<>
<Button
size="sm"
variant="secondary"
className="rounded-xl"
aria-label="Поиск по сообщениям"
onClick={() => {
setChatToolsTab('search');
setChatToolsOpen(true);
}}
>
<Search className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="secondary"
className="rounded-xl"
aria-label="Перейти к дате"
onClick={() => {
setChatToolsTab('calendar');
setChatToolsOpen(true);
}}
>
<CalendarDays className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="secondary"
className="rounded-xl"
aria-label="Медиа чата"
onClick={() => {
setChatToolsTab('media');
setChatToolsOpen(true);
}}
>
<ImageIcon className="h-4 w-4" />
</Button>
</>
) : null}
{canManageActiveBot ? (
<Button
size="sm"
variant="secondary"
className="rounded-xl"
aria-label="Настройки бота"
title="Настройки бота"
onClick={() => {
setMiniAppTitle('Настройки бота');
setMiniAppUrl(botChatMeta!.manageWebAppUrl!);
}}
>
<Settings 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>
{activeRoom.type !== 'GENERAL' ? (
<Button
size="sm"
variant="secondary"
className="rounded-xl text-red-600 hover:text-red-700"
disabled={deletingRoomId === activeRoom.id}
onClick={() => void handleDeleteRoom(activeRoom.id)}
>
{deletingRoomId === activeRoom.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</Button>
) : null}
</div>
</div>
{activeRoomIsBot && activeRoom.botUsername ? (
<FamilyBotChat
botRef={activeRoom.botUsername}
botName={activeRoomTitle}
token={token}
onBotMetaChange={setBotChatMeta}
onOpenMiniApp={(url) => {
setMiniAppTitle('Mini App');
setMiniAppUrl(url);
}}
/>
) : (
<>
<div className={cn('flex-1 space-y-2 overflow-y-auto px-4 py-4', isDragging && 'select-none')}>
{visibleMessages.map((message, messageIndex) => {
const previousMessage = visibleMessages[messageIndex - 1];
const showDateSeparator = shouldShowChatDateSeparator(message.createdAt, previousMessage?.createdAt);
if (isSystemMessage(message)) {
return (
<div key={message.id}>
{showDateSeparator ? <ChatDateSeparator date={message.createdAt} /> : null}
<div id={`msg-${message.id}`} className="flex justify-center px-2 py-1">
<p className="rounded-full bg-black/[0.06] px-3 py-1.5 text-xs text-[#667085]">
{getMessagePreviewText(message)}
</p>
</div>
</div>
);
}
const mine = message.senderId === user?.id;
const e2ePayload = message.isEncrypted ? e2ePayloads[message.id] : null;
const visibleText = getE2EMessageText({
message,
isE2E: activeRoomIsE2E,
peerE2eKey,
peerKeyLoading,
e2ePayload,
e2eError: e2eErrors[message.id]
});
const emojiContent =
message.type === 'EMOJI' || e2ePayload?.kind === 'emoji'
? e2ePayload?.emojiId ?? message.content
: null;
const forwardedFrom = parseForwardedFrom(message.metadataJson);
const repliedMessage = message.replyToId ? messageById.get(message.replyToId) : null;
const repliedVisibleText = repliedMessage
? getE2EMessageText({
message: repliedMessage,
isE2E: activeRoomIsE2E,
peerE2eKey,
peerKeyLoading,
e2ePayload: repliedMessage.isEncrypted ? e2ePayloads[repliedMessage.id] : null,
e2eError: e2eErrors[repliedMessage.id]
})
: '';
const isSelected = selectedMessageIds.includes(message.id);
const isLargeEmoji = isSingleEmojiMessage({
type: message.type,
content: emojiContent ?? message.content,
visibleText
});
const largeEmojiNative = isLargeEmoji
? getSingleEmojiNative({ type: message.type, content: emojiContent ?? message.content, visibleText })
: '';
const bubble = (
<div
className={cn(
'relative',
isLargeEmoji
? 'bg-transparent px-0 py-0 shadow-none'
: cn(
'rounded-[18px] px-3 py-2 shadow-sm',
mine ? 'rounded-br-md bg-[#effdde] text-[#1f2430]' : 'rounded-bl-md bg-white text-[#1f2430]',
!selectionMode && 'max-w-[78%]'
)
)}
>
{!mine ? <p className="mb-1 text-xs font-semibold text-[#3390ec]">{message.senderName}</p> : null}
{forwardedFrom ? (
<p className="mb-1 text-xs font-medium text-[#3390ec]"> Переслано от {forwardedFrom.senderName}</p>
) : null}
{repliedMessage ? (
<RepliedMessagePreview
senderName={repliedMessage.senderName}
preview={getMessagePreviewText(repliedMessage, repliedVisibleText)}
onClick={() => scrollToChatMessage(repliedMessage.id)}
className={mine ? 'bg-black/[0.06]' : undefined}
/>
) : 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 = e2ePayload?.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 = e2ePayload?.durationMs ?? 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>
);
})()
) : isLargeEmoji ? (
<div className="relative inline-flex min-w-[72px] flex-col items-center">
<span className="text-[56px] leading-none">{largeEmojiNative}</span>
<div className="absolute bottom-0 right-0 flex items-center gap-0.5 rounded-full bg-black/45 px-1.5 py-0.5 text-[10px] text-white">
{message.editedAt ? <span>изм.</span> : null}
<span>{formatMessageTime(message.createdAt)}</span>
{mine ? (
message.readByAll ? (
<CheckCheck className="h-3 w-3" aria-label="Прочитано" />
) : (
<Check className="h-3 w-3" aria-label="Отправлено" />
)
) : null}
</div>
</div>
) : emojiContent ? (
<div className="py-1">
<ChatEmojiContent content={emojiContent} size={32} />
</div>
) : (
<div className="whitespace-pre-wrap break-words text-[15px] leading-relaxed">
<ChatEmojiContent content={visibleText} size={22} />
</div>
)}
{!isLargeEmoji ? (
<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.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>
) : null}
{!isLargeEmoji ? (
<ChatMessageReactions
reactions={message.reactions}
onToggle={(emoji) => void handleToggleReaction(message.id, emoji)}
/>
) : null}
</div>
);
return (
<div key={message.id}>
{showDateSeparator ? <ChatDateSeparator date={message.createdAt} /> : null}
<div
id={`msg-${message.id}`}
className={cn('group flex gap-2', mine ? 'justify-end' : 'justify-start', isDragging && 'select-none')}
onMouseDown={(event) => {
if (isChatInteractiveTarget(event.target)) return;
handleMessagePointerDown(message.id, event);
}}
onMouseEnter={() => handleDragEnterMessage(message.id)}
onClick={(event) => {
if (selectionMode) return;
if (event.ctrlKey || event.metaKey || event.shiftKey) {
handleSelectionPointer(message.id, event);
}
}}
>
{!mine ? (
<UserAvatar
userId={message.senderId}
displayName={message.senderName}
hasAvatar={message.senderHasAvatar}
token={token}
isVerified={message.senderIsVerified}
verificationIcon={message.senderVerificationIcon}
className="z-10 -mr-3 -mt-0.5 h-8 w-8 shrink-0 self-start ring-2 ring-[#eef1f6]"
badgeSize="xs"
/>
) : null}
{selectionMode ? (
<SelectableMessageWrapper
selected={isSelected}
selectionMode
align={mine ? 'right' : 'left'}
onClick={() => toggleSelectedMessage(message.id)}
>
{bubble}
</SelectableMessageWrapper>
) : (
<ChatMessageContextMenu
message={message}
userId={user?.id}
isE2E={activeRoomIsE2E}
visibleText={visibleText}
onEdit={() => void startEditMessage(message)}
onDelete={() => requestDeleteMessages([message.id])}
onReply={() => {
exitSelectionMode();
setReplyToMessage(message);
setEditingMessageId(null);
setDraft('');
setEmojiOpen(false);
}}
onForward={() => {
setSelectedMessageIds([message.id]);
setForwardDialogOpen(true);
}}
onSelect={() => enterSelectionMode(message.id)}
onToggleReaction={(emoji) => void handleToggleReaction(message.id, emoji)}
onCopy={() => showToast('Текст скопирован')}
>
{bubble}
</ChatMessageContextMenu>
)}
{mine ? (
<UserAvatar
userId={user?.id ?? message.senderId}
displayName={user?.displayName ?? message.senderName}
hasAvatar={Boolean(user?.hasAvatar)}
token={token}
isVerified={user?.isVerified}
verificationIcon={user?.verificationIcon}
className="z-10 -ml-3 -mt-0.5 h-8 w-8 shrink-0 self-start ring-2 ring-[#eef1f6]"
badgeSize="xs"
/>
) : null}
</div>
</div>
);
})}
<TypingIndicator roomType={activeRoom.type} typers={typers} />
<div ref={messagesEndRef} />
</div>
{selectionMode ? (
<ChatSelectionToolbar
count={selectedMessageIds.length}
deleting={bulkDeleting}
onCancel={exitSelectionMode}
onCopy={() => void handleBulkCopy()}
onForward={() => setForwardDialogOpen(true)}
onDelete={() => void handleBulkDelete()}
/>
) : (
<div className="border-t border-[#dce3ec] bg-white px-3 py-3">
{editingMessageId ? (
<ChatMessageEditBanner
preview={draft.trim() || messages.find((item) => item.id === editingMessageId)?.content || undefined}
onCancel={cancelEditMessage}
/>
) : null}
{replyToMessage ? (
<div className="mb-2 flex items-center justify-between rounded-xl bg-[#f4f5f8] px-3 py-2 text-sm">
<div className="min-w-0">
<p className="font-medium text-[#3390ec]">Ответ для {replyToMessage.senderName}</p>
<p className="truncate text-[#667085]">{getMessageCopyText(replyToMessage) || 'Сообщение'}</p>
</div>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => setReplyToMessage(null)}>
<X className="h-4 w-4" />
</Button>
</div>
) : null}
{emojiOpen ? (
<div className="mb-2">
<EmojiPicker
className="w-full"
onSelect={(nativeEmoji) => insertEmojiToDraft(nativeEmoji)}
/>
</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>
{!activeRoomIsE2E ? (
<Button size="sm" variant="ghost" className="h-10 w-10 rounded-xl p-0" onClick={() => setPollOpen(true)}>
<BarChart3 className="h-4 w-4" />
</Button>
) : null}
<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={editingMessageId ? 'Измените сообщение' : 'Сообщение'}
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();
if (editingMessageId) {
void submitEditMessage();
} else {
void sendText();
}
}
if (event.key === 'Escape' && editingMessageId) {
cancelEditMessage();
}
}}
/>
{draft.trim() || editingMessageId ? (
<Button
className="h-11 w-11 rounded-full p-0"
disabled={sending}
onClick={() => {
if (editingMessageId) {
void submitEditMessage();
} else {
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={fileInputRef} type="file" className="hidden" onChange={(event) => {
const file = event.target.files?.[0];
if (file) void uploadChatFile(file);
event.target.value = '';
}} />
<FamilyInviteDialog
groupId={groupId}
group={group}
open={inviteOpen}
onOpenChange={setInviteOpen}
onInvited={() => void loadGroup()}
/>
<ChatForwardDialog
open={forwardDialogOpen}
onOpenChange={setForwardDialogOpen}
rooms={rooms}
viewerUserId={user?.id ?? ''}
token={token}
currentRoomId={activeRoomId ?? undefined}
forwarding={forwarding}
onSelectRoom={(roomId) => void handleForwardToRoom(roomId)}
/>
<ChatToolsDialog
open={chatToolsOpen}
onOpenChange={setChatToolsOpen}
messages={visibleMessages}
defaultTab={chatToolsTab}
onJumpToMessage={scrollToChatMessage}
getMessagePreview={(message) =>
getMessagePreviewText(
message,
getE2EMessageText({
message,
isE2E: activeRoomIsE2E,
peerE2eKey,
peerKeyLoading,
e2ePayload: message.isEncrypted ? e2ePayloads[message.id] : null,
e2eError: e2eErrors[message.id]
})
)
}
getMediaUrl={(message) => (message.storageKey ? mediaUrls[message.storageKey]?.blobUrl ?? null : null)}
/>
<ChatDeleteMessageDialog
open={deleteMessagesDialogOpen}
count={pendingDeleteMessageIds.length}
loading={bulkDeleting}
onOpenChange={(open) => {
setDeleteMessagesDialogOpen(open);
if (!open) setPendingDeleteMessageIds([]);
}}
onConfirm={() => void confirmDeleteMessages()}
/>
<BotMiniAppSheet url={miniAppUrl} title={miniAppTitle} onClose={() => setMiniAppUrl(null)} />
<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">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
onClick={() => {
if (!isSelf) {
void openMemberChatFromList(member.userId, { isBot: member.isBot });
}
}}
>
<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}
{member.isBot ? ' 🤖' : null}
{isSelf ? <span className="ml-1 text-xs font-normal text-[#667085]">(Вы)</span> : null}
</p>
<p className="text-xs text-[#667085]">{member.isBot ? 'Бот' : familyMemberRoleLabel(member.role)}</p>
{!isSelf ? (
<p className={cn('text-xs', member.isBot ? 'text-[#3390ec]' : presence?.online ? 'font-medium text-emerald-600' : 'text-[#a8adbc]')}>
{member.isBot ? 'Открыть чат с ботом' : presence ? formatPresenceStatus(presence) : 'Статус неизвестен'}
</p>
) : null}
</div>
</button>
{!isSelf && !member.isBot ? (
<Button
size="sm"
variant="ghost"
className="h-8 shrink-0 rounded-xl text-xs text-[#3390ec]"
onClick={(event) => {
event.stopPropagation();
void createSecretChatWithMember(member.userId);
}}
>
🔒 E2E
</Button>
) : null}
{canRemove ? (
<Button
size="sm"
variant="ghost"
className="h-8 shrink-0 rounded-xl text-xs text-red-600 hover:text-red-700"
onClick={(event) => {
event.stopPropagation();
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>
);
}