Files
IdP/apps/frontend/components/family/family-group-view.tsx
2026-06-29 21:36:32 +03:00

2907 lines
120 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,
ArrowRightLeft,
BarChart3,
CalendarDays,
Camera,
Check,
CheckCheck,
FileText,
ImageIcon,
Loader2,
LogOut,
Mic,
Paperclip,
Pin,
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 { ChatImageLightbox } from '@/components/chat/chat-image-lightbox';
import { ChatMediaAlbumGrid } from '@/components/chat/chat-media-album-grid';
import {
ChatMediaComposeDialog,
ChatMediaDropOverlay,
extractFilesFromClipboard,
extractFilesFromDataTransfer,
useChatMediaComposer
} from '@/components/chat/chat-media-compose-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, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
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 { NotificationBell } from '@/components/notifications/notification-bell';
import { UserMenu } from '@/components/id/user-menu';
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,
leaveFamilyGroup,
markChatRoomRead,
removeChatRoomMember,
removeFamilyMember,
reportChatTyping,
sendChatMessage,
setChatMessagePinned,
setChatRoomPinned,
setChatRoomMuted,
toggleChatMessageReaction,
transferFamilyOwnership,
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';
import { collectChatImageMessages, parseChatMediaMetadata } from '@/lib/chat-media-album';
import { uploadChatMediaBatch } from '@/lib/chat-media-upload';
interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
expiresAt?: string;
uploadToken?: string;
}
interface LoadedChatMedia {
blobUrl?: string;
expiresAt?: number;
fileName?: string;
mimeType?: string;
status: 'loading' | 'ready' | 'error';
errorMessage?: 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' ? 'Создатель семьи' : 'Участник';
}
function shouldSkipGroupedAlbumMessage(messages: ChatMessage[], index: number) {
const message = messages[index];
if (!message) return false;
const meta = parseChatMediaMetadata(message.metadataJson);
if (!meta.albumId) return false;
const previous = messages[index - 1];
if (!previous) return false;
const previousMeta = parseChatMediaMetadata(previous.metadataJson);
return previous.senderId === message.senderId && previousMeta.albumId === meta.albumId;
}
function collectAlbumMessages(messages: ChatMessage[], startIndex: number) {
const first = messages[startIndex]!;
const albumId = parseChatMediaMetadata(first.metadataJson).albumId;
if (!albumId) return [first];
const result: ChatMessage[] = [];
for (let index = startIndex; index < messages.length; index += 1) {
const message = messages[index]!;
const meta = parseChatMediaMetadata(message.metadataJson);
if (message.senderId !== first.senderId || meta.albumId !== albumId) break;
result.push(message);
}
return result;
}
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 [leaveFamilyDialogOpen, setLeaveFamilyDialogOpen] = useState(false);
const [transferOwnershipDialogOpen, setTransferOwnershipDialogOpen] = useState(false);
const [leavingFamily, setLeavingFamily] = useState(false);
const [transferringOwnership, setTransferringOwnership] = useState(false);
const [selectedNewOwnerUserId, setSelectedNewOwnerUserId] = useState<string | null>(null);
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 [pinningRoomId, setPinningRoomId] = 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 [mediaDropVisible, setMediaDropVisible] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const mediaComposer = useChatMediaComposer();
const messagesEndRef = useRef<HTMLDivElement>(null);
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
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 mediaLoadInFlightRef = useRef<Set<string>>(new Set());
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
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 chatImageMessages = useMemo(() => collectChatImageMessages(visibleMessages), [visibleMessages]);
const messageById = useMemo(
() => new Map(visibleMessages.map((message) => [message.id, message])),
[visibleMessages]
);
const pinnedMessage = useMemo(
() =>
visibleMessages
.filter((message) => message.isPinned)
.sort((left, right) => new Date(right.pinnedAt ?? right.createdAt).getTime() - new Date(left.pinnedAt ?? left.createdAt).getTime())[0] ?? null,
[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 pinnedVisibleText = pinnedMessage
? getE2EMessageText({
message: pinnedMessage,
isE2E: activeRoomIsE2E,
peerE2eKey,
peerKeyLoading,
e2ePayload: pinnedMessage.isEncrypted ? e2ePayloads[pinnedMessage.id] : null,
e2eError: e2eErrors[pinnedMessage.id]
})
: '';
const pinnedPreview = pinnedMessage ? getMessagePreviewText(pinnedMessage, pinnedVisibleText) : '';
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 humanMembers = useMemo(
() => (group?.members ?? []).filter((member) => !member.isBot && member.role !== 'bot'),
[group?.members]
);
const otherHumanMembers = useMemo(
() => humanMembers.filter((member) => member.userId !== user?.id),
[humanMembers, user?.id]
);
const ownerMustTransfer = Boolean(isFamilyOwner && otherHumanMembers.length > 0);
const ownerLeaveDeletesFamily = Boolean(isFamilyOwner && otherHumanMembers.length === 0);
const mobileChatOpen = Boolean(activeRoomId);
const isChatCreator = activeRoom?.createdById === user?.id;
const canManageChatMembers = Boolean(isFamilyOwner || isChatCreator || activeRoomIsBot);
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 room = rooms.find((item) => item.id === roomId);
if (!room || room.type === 'BOT') return;
try {
const response = await fetchChatMessages(roomId, token);
setMessages((response.messages ?? []).filter((message) => !message.isDeleted));
} catch (error) {
const message = getApiErrorMessage(error, 'Не удалось загрузить сообщения');
if (message && !message.includes('Bot API')) {
showToast(message);
}
}
}, [isPinLocked, rooms, showToast, 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;
if (!rooms.some((room) => room.id === activeRoomId)) return;
void loadMessages(activeRoomId);
setEditingMessageId(null);
setDraft('');
}, [activeRoomId, activeRoomIsBot, loadMessages, rooms]);
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(() => {
autoScrollStateRef.current = { id: null, count: 0 };
}, [activeRoomId]);
useEffect(() => {
const lastMessage = visibleMessages[visibleMessages.length - 1];
const previous = autoScrollStateRef.current;
const next = { id: lastMessage?.id ?? null, count: visibleMessages.length };
autoScrollStateRef.current = next;
if (!next.id) return;
const lastMessageChanged = previous.id !== next.id;
const messageWasAppended = next.count >= previous.count;
if (lastMessageChanged && messageWasAppended) {
messagesEndRef.current?.scrollIntoView({ behavior: previous.id ? 'smooth' : 'auto' });
}
}, [visibleMessages]);
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 && !activeRoomIsBot) {
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 && !activeRoomIsBot) {
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 && !activeRoomIsBot) {
void loadMessages(activeRoomId);
}
void loadGroup();
return;
}
if (event.type === 'chat_read_receipt' && activeRoomId && !activeRoomIsBot) {
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');
return;
}
if (event.type === 'family_left_self' && payload?.groupId === groupId) {
router.push('/family');
return;
}
if (event.type === 'family_member_left' && payload?.groupId === groupId) {
const userName = typeof payload?.userName === 'string' ? payload.userName : 'Участник';
showToast(`${userName} покинул(а) семью`);
void loadGroup();
return;
}
if (event.type === 'family_member_removed' && payload?.groupId === groupId) {
const userName = typeof payload?.userName === 'string' ? payload.userName : 'Участник';
showToast(`${userName} удалён(а) из семьи`);
void loadGroup();
return;
}
if (event.type === 'family_ownership_transferred' && payload?.groupId === groupId) {
showToast('Вы стали главой семьи');
void loadGroup();
}
});
}, [
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?.status === 'ready' && existing.blobUrl && existing.expiresAt && !shouldRefreshMedia(existing.expiresAt)) {
return;
}
if (mediaLoadInFlightRef.current.has(storageKey) && !force) {
return;
}
if (message.isEncrypted && activeRoomIsE2E) {
if (peerKeyLoading) return;
if (!peerE2eKey || !user?.id || !message.content) {
setMediaUrls((current) => ({
...current,
[storageKey]: {
...current[storageKey],
status: 'error',
errorMessage: 'Не удалось расшифровать файл'
}
}));
return;
}
}
mediaLoadInFlightRef.current.add(storageKey);
setMediaUrls((current) => ({
...current,
[storageKey]: {
...current[storageKey],
status: 'loading',
errorMessage: undefined
}
}));
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,
status: 'ready'
}
};
});
} catch (error) {
setMediaUrls((current) => ({
...current,
[storageKey]: {
...current[storageKey],
status: 'error',
errorMessage: error instanceof Error ? error.message : 'Не удалось загрузить файл'
}
}));
} finally {
mediaLoadInFlightRef.current.delete(storageKey);
}
},
[activeRoomId, activeRoomIsE2E, peerE2eKey, peerKeyLoading, token, user?.id]
);
const cacheUploadedMedia = useCallback((storageKey: string, file: File) => {
const blobUrl = createBlobObjectUrl(file);
setMediaUrls((current) => {
revokeBlobObjectUrl(current[storageKey]?.blobUrl);
return {
...current,
[storageKey]: {
blobUrl,
expiresAt: Date.now() + 30 * 60_000,
fileName: file.name,
mimeType: file.type || resolveChatContentType(file),
status: 'ready'
}
};
});
}, []);
useEffect(() => {
if (!token || !activeRoomId) return;
messages.forEach((message) => {
if (message.storageKey) void loadChatMedia(message);
});
}, [activeRoomId, loadChatMedia, messages, peerE2eKey, peerKeyLoading, 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?.status === 'ready' && cached.expiresAt && 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)));
}
function sortRoomsForViewer(roomList: ChatRoom[]) {
return [...roomList].sort((a, b) => {
const pinDiff = Number(Boolean(b.isPinned)) - Number(Boolean(a.isPinned));
if (pinDiff !== 0) return pinDiff;
if (a.isPinned && b.isPinned) {
return (b.pinnedAt ?? '').localeCompare(a.pinnedAt ?? '');
}
const rank = (type: string) => (type === 'GENERAL' ? 0 : 1);
const rankDiff = rank(a.type) - rank(b.type);
if (rankDiff !== 0) return rankDiff;
const aTime = a.lastMessage?.createdAt ?? a.updatedAt;
const bTime = b.lastMessage?.createdAt ?? b.updatedAt;
return bTime.localeCompare(aTime);
});
}
async function handleToggleRoomPin(room: ChatRoom) {
if (!token) return;
setPinningRoomId(room.id);
try {
const updated = await setChatRoomPinned(room.id, !room.isPinned, token);
setRooms((current) => sortRoomsForViewer(current.map((item) => (item.id === updated.id ? updated : item))));
showToast(updated.isPinned ? 'Чат закреплён' : 'Чат откреплён');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось закрепить чат') ?? 'Ошибка');
} finally {
setPinningRoomId(null);
}
}
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);
await loadGroup();
setMembersOpen(false);
setFamilyMembersOpen(false);
showToast('Участник удалён из семьи');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось удалить участника из семьи') ?? 'Ошибка');
}
}
function openLeaveFamilyDialog() {
setSelectedNewOwnerUserId(otherHumanMembers[0]?.userId ?? null);
setLeaveFamilyDialogOpen(true);
}
function openTransferOwnershipDialog() {
setSelectedNewOwnerUserId(otherHumanMembers[0]?.userId ?? null);
setTransferOwnershipDialogOpen(true);
}
async function confirmTransferOwnership() {
if (!token || !selectedNewOwnerUserId) {
showToast('Выберите нового главу семьи');
return;
}
setTransferringOwnership(true);
try {
const updated = await transferFamilyOwnership(groupId, selectedNewOwnerUserId, token);
setGroup(updated);
setTransferOwnershipDialogOpen(false);
setFamilyMembersOpen(false);
showToast('Управление семьёй передано');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось передать управление семьёй') ?? 'Ошибка');
} finally {
setTransferringOwnership(false);
}
}
async function confirmLeaveFamily() {
if (!token) return;
if (ownerMustTransfer && !selectedNewOwnerUserId) {
showToast('Выберите нового главу семьи');
return;
}
setLeavingFamily(true);
try {
const result = await leaveFamilyGroup(
groupId,
token,
ownerMustTransfer ? selectedNewOwnerUserId ?? undefined : undefined
);
setLeaveFamilyDialogOpen(false);
setFamilyMembersOpen(false);
showToast(result.deleted ? 'Семья удалена' : 'Вы покинули семью');
router.push('/family');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось покинуть семью') ?? 'Ошибка');
} finally {
setLeavingFamily(false);
}
}
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, 'Не удалось поставить реакцию') ?? 'Ошибка');
}
}
async function handleTogglePin(message: ChatMessage) {
if (!token) return;
try {
const updated = await setChatMessagePinned(message.id, !message.isPinned, token);
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
showToast(updated.isPinned ? 'Сообщение закреплено' : 'Сообщение откреплено');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось изменить закрепление') ?? 'Ошибка');
}
}
function handleSelectionReply() {
const selected = getSelectedMessages();
if (selected.length !== 1) {
showToast('Ответить можно только на одно сообщение');
return;
}
exitSelectionMode();
setReplyToMessage(selected[0]!);
setEditingMessageId(null);
setDraft('');
setEmojiOpen(false);
}
function handleSelectionPin() {
const selected = getSelectedMessages();
if (selected.length !== 1) {
showToast('Закрепить можно только одно сообщение');
return;
}
void handleTogglePin(selected[0]!);
}
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;
if (!options?.voice) {
queueMediaFiles([file]);
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);
cacheUploadedMedia(presigned.storageKey, file);
void loadGroup();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить файл') ?? 'Ошибка');
} finally {
setSending(false);
}
}
const queueMediaFiles = useCallback(
(files: File[], options?: { sendAsFile?: boolean }) => {
if (!activeRoom || activeRoomIsBot || !files.length) return;
if (files.length > mediaComposer.maxFiles) {
showToast(`Можно отправить не более ${mediaComposer.maxFiles} файлов за раз`);
}
const limited = files.slice(0, mediaComposer.maxFiles);
if (mediaComposer.open && mediaComposer.items.length) {
void mediaComposer.addFiles(limited);
return;
}
void mediaComposer.openWithFiles(limited, options);
},
[activeRoom, activeRoomIsBot, mediaComposer]
);
async function submitMediaCompose() {
if (!token || !activeRoomId || !activeRoom || !mediaComposer.items.length) return;
setSending(true);
try {
const uploaded = await uploadChatMediaBatch({
roomId: activeRoomId,
token,
items: mediaComposer.items.map((item) => ({
file: item.file,
sendAsFile: item.sendAsFile,
width: item.width,
height: item.height
})),
caption: mediaComposer.caption,
isE2E: activeRoomIsE2E,
userId: user?.id,
peerE2eKey,
encryptOutgoing
});
uploaded.forEach((message, index) => {
appendMessage(message);
const sourceFile = mediaComposer.items[index]?.file;
if (message.storageKey && sourceFile) {
cacheUploadedMedia(message.storageKey, sourceFile);
}
});
mediaComposer.close();
void loadGroup();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить файлы') ?? 'Ошибка');
} finally {
setSending(false);
}
}
useEffect(() => {
if (!activeRoom || activeRoomIsBot) return;
function handlePaste(event: ClipboardEvent) {
const files = extractFilesFromClipboard(event.clipboardData);
if (!files.length) return;
event.preventDefault();
queueMediaFiles(files, { sendAsFile: false });
}
window.addEventListener('paste', handlePaste);
return () => window.removeEventListener('paste', handlePaste);
}, [activeRoom, activeRoomIsBot, queueMediaFiles]);
function openImageLightbox(message: ChatMessage) {
const index = chatImageMessages.findIndex((item) => item.id === message.id);
if (index >= 0) setLightboxIndex(index);
}
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="flex h-[calc(100dvh-4.5rem-env(safe-area-inset-bottom))] items-center justify-center text-[#667085] lg:h-auto lg:py-20">
Загрузка семьи...
</div>
);
}
return (
<div
className={cn(
'flex w-full min-w-0 max-w-full overflow-hidden bg-[#eef2f8]',
'h-[calc(100dvh-4.5rem-env(safe-area-inset-bottom))] min-h-0 lg:h-[calc(100vh-120px)] lg:min-h-[640px]',
'pt-[env(safe-area-inset-top)] lg:pt-0',
'rounded-none border-0 lg:rounded-[28px] lg:border lg:border-[#eceef4]'
)}
>
<aside
className={cn(
'flex min-w-0 max-w-full flex-col overflow-hidden border-r border-[#e4e8ef] bg-white',
'w-full lg:w-[300px] lg:shrink-0',
mobileChatOpen ? 'hidden lg:flex' : 'flex'
)}
>
<div className="shrink-0 border-b border-[#eceef4] p-3 sm:p-4">
<div className="mb-3 flex items-center justify-between gap-2">
<button
type="button"
className="flex min-w-0 items-center gap-2 text-sm text-[#667085]"
onClick={() => router.push('/family')}
>
<ArrowLeft className="h-4 w-4 shrink-0" />
<span className="truncate">Все семьи</span>
</button>
<div className="flex shrink-0 items-center gap-1 lg:hidden">
<NotificationBell />
<UserMenu className="border-0 bg-transparent p-0 pr-0 shadow-none" />
</div>
</div>
<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="h-10 flex-1 rounded-xl text-sm" onClick={() => setInviteOpen(true)}>
<UserPlus className="mr-1 h-4 w-4 shrink-0" />
Пригласить
</Button>
</div>
<button
type="button"
onClick={() => setFamilyMembersOpen(true)}
className="mt-3 flex w-full min-w-0 items-center gap-3 rounded-2xl bg-[#f4f5f8] px-3 py-2.5 text-left transition hover:bg-[#eceef4] sm:py-3"
>
<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 shrink-0 items-center justify-between px-3 py-2.5 sm:px-4 sm:py-3">
<p className="text-sm font-semibold text-[#667085]">Чаты</p>
<Button size="sm" variant="ghost" className="h-8 w-8 shrink-0 rounded-xl p-0" onClick={() => setCreateRoomOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain">
{rooms.map((room) => {
const roomLabel = user ? roomDisplayLabel(room, user.id) : room.name;
const preview =
getChatListPreviewText(room.lastMessage) || (room.type === 'GENERAL' ? 'Общий чат семьи' : 'Нет сообщений');
return (
<div
key={room.id}
className={cn(
'group relative min-w-0 overflow-hidden transition hover:bg-[#f7f9fc]',
activeRoomId === room.id && 'bg-[#3390ec]/10'
)}
>
<button
type="button"
onClick={() => setActiveRoomId(room.id)}
className="flex w-full min-w-0 items-start gap-3 py-3 pl-3 pr-11 text-left sm:pl-4 sm:pr-12"
>
<div className="shrink-0">
<ChatRoomAvatarDisplay room={room} viewerUserId={user?.id ?? ''} token={token} size="sm" />
</div>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-[15px] font-medium leading-tight">{roomLabel}</span>
{room.lastMessage ? (
<span className="shrink-0 tabular-nums text-[11px] leading-none text-[#a8adbc]">
{formatMessageTime(room.lastMessage.createdAt)}
</span>
) : null}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5">
{room.peerLeftFamily ? (
<span className="shrink-0 rounded bg-amber-50 px-1 py-0.5 text-[10px] font-medium leading-none text-amber-700">
не в семье
</span>
) : null}
{room.isPinned ? (
<Pin className="h-3 w-3 shrink-0 fill-[#8a8f9e] text-[#8a8f9e] lg:hidden" />
) : null}
<p className="min-w-0 flex-1 truncate text-sm leading-snug text-[#667085]">{preview}</p>
</div>
</div>
</button>
<button
type="button"
aria-label={room.isPinned ? 'Открепить чат' : 'Закрепить чат'}
disabled={pinningRoomId === room.id}
className={cn(
'absolute right-1 top-1/2 hidden h-9 w-9 -translate-y-1/2 items-center justify-center rounded-lg text-[#8a8f9e] transition hover:bg-[#eceef4] hover:text-[#667085] sm:right-2 lg:flex',
room.isPinned ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
pinningRoomId === room.id && 'opacity-100'
)}
onClick={() => void handleToggleRoomPin(room)}
>
{pinningRoomId === room.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Pin className={cn('h-4 w-4', room.isPinned && 'fill-[#8a8f9e]')} />
)}
</button>
</div>
);
})}
</div>
</aside>
<section
className={cn(
'min-w-0 max-w-full flex-1 flex-col overflow-hidden bg-[#e6ebf2]',
mobileChatOpen ? 'flex' : 'hidden lg:flex'
)}
>
{activeRoom ? (
<>
<div className="flex items-center justify-between gap-2 border-b border-[#dce3ec] bg-white px-3 py-3 sm:px-4 lg:pr-48">
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
<button
type="button"
aria-label="Назад к списку чатов"
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl text-[#667085] transition hover:bg-[#f4f5f8] lg:hidden"
onClick={() => setActiveRoomId(null)}
>
<ArrowLeft className="h-5 w-5" />
</button>
{(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 overflow-hidden text-left" onClick={() => setMembersOpen(true)}>
<h2 className="truncate text-base font-semibold sm:text-lg">{activeRoomTitle}</h2>
<p className="truncate text-xs text-[#667085]">
{activeRoomIsBot ? (
<>🤖 Бот · {activeRoom.members.length} участников</>
) : activeRoomIsE2E ? (
<>
🔒 E2E · секретный чат
{activeRoom.peerLeftFamily ? (
<span className="ml-1 text-amber-600">· собеседник не в семье</span>
) : null}
</>
) : activeRoom.type === 'DIRECT' ? (
<>
Личный чат · {activeRoom.members.length} участников
{activeRoom.peerLeftFamily ? (
<span className="ml-1 text-amber-600">· собеседник не в семье</span>
) : null}
</>
) : (
<>
{onlineCount > 0 ? (
<span className="text-emerald-600">{onlineCount} в сети</span>
) : (
'Никого нет в сети'
)}
{' · '}
{activeRoom.members.length} участников
</>
)}
</p>
</button>
</div>
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
{!activeRoomIsBot ? (
<>
<Button
size="sm"
variant="secondary"
className="h-9 w-9 shrink-0 rounded-xl p-0"
aria-label="Поиск по сообщениям"
onClick={() => {
setChatToolsTab('search');
setChatToolsOpen(true);
}}
>
<Search className="h-4 w-4 shrink-0" />
</Button>
<Button
size="sm"
variant="secondary"
className="hidden h-9 w-9 shrink-0 rounded-xl p-0 sm:inline-flex"
aria-label="Перейти к дате"
onClick={() => {
setChatToolsTab('calendar');
setChatToolsOpen(true);
}}
>
<CalendarDays className="h-4 w-4 shrink-0" />
</Button>
<Button
size="sm"
variant="secondary"
className="h-9 w-9 shrink-0 rounded-xl p-0"
aria-label="Медиа чата"
onClick={() => {
setChatToolsTab('media');
setChatToolsOpen(true);
}}
>
<ImageIcon className="h-4 w-4 shrink-0" />
</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}
roomId={activeRoom.id}
onBotMetaChange={setBotChatMeta}
onOpenMiniApp={(url) => {
setMiniAppTitle('Mini App');
setMiniAppUrl(url);
}}
/>
) : (
<div
className="relative flex min-h-0 flex-1 flex-col"
onDragEnter={(event) => {
if (activeRoomIsBot) return;
event.preventDefault();
dragDepthRef.current += 1;
setMediaDropVisible(true);
}}
onDragLeave={(event) => {
if (activeRoomIsBot) return;
event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setMediaDropVisible(false);
}}
onDragOver={(event) => {
if (activeRoomIsBot) return;
event.preventDefault();
}}
onDrop={(event) => {
if (activeRoomIsBot) return;
event.preventDefault();
dragDepthRef.current = 0;
setMediaDropVisible(false);
const files = extractFilesFromDataTransfer(event.dataTransfer);
if (files.length) queueMediaFiles(files, { sendAsFile: false });
}}
>
<ChatMediaDropOverlay
visible={mediaDropVisible && !activeRoomIsBot}
onDropAsFile={(files) => {
dragDepthRef.current = 0;
setMediaDropVisible(false);
queueMediaFiles(files, { sendAsFile: true });
}}
onDropCompressed={(files) => {
dragDepthRef.current = 0;
setMediaDropVisible(false);
queueMediaFiles(files, { sendAsFile: false });
}}
/>
{pinnedMessage ? (
<ChatPinnedMessageBanner
senderName={pinnedMessage.senderName}
preview={pinnedPreview}
onClick={() => {
if (!scrollToChatMessage(pinnedMessage.id)) {
showToast('Закреплённое сообщение пока не загружено в ленте');
}
}}
/>
) : null}
<div className={cn('flex-1 space-y-2 overflow-y-auto px-4 py-4', isDragging && 'select-none')}>
{visibleMessages.map((message, messageIndex) => {
if (shouldSkipGroupedAlbumMessage(visibleMessages, messageIndex)) {
return null;
}
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 albumMessages = collectAlbumMessages(visibleMessages, messageIndex);
const isAlbumGroup = albumMessages.length > 1 && Boolean(parseChatMediaMetadata(message.metadataJson).albumId);
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 ? (
(() => {
if (isAlbumGroup) {
const captionMessage = albumMessages.find((item) => item.content?.trim());
return (
<div className="space-y-2">
<ChatMediaAlbumGrid
messages={albumMessages}
mediaUrls={mediaUrls}
onImageClick={(item) => openImageLightbox(item)}
/>
{captionMessage?.content ? (
<p className="whitespace-pre-wrap text-sm">{captionMessage.content}</p>
) : null}
</div>
);
}
const media = mediaUrls[message.storageKey];
const meta = parseMessageMetadata(message.metadataJson);
const fileName = e2ePayload?.fileName || meta.fileName || message.content || 'Файл';
if (media?.status === 'error') {
return (
<div className="space-y-2 text-sm text-[#667085]">
<p>{media.errorMessage ?? 'Не удалось загрузить файл'}</p>
<button
type="button"
className="font-medium text-[#3390ec] hover:underline"
onClick={() => void loadChatMedia(message, true)}
>
Повторить
</button>
</div>
);
}
if (!media?.blobUrl || media.status === 'loading') {
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 (
<button type="button" className="block overflow-hidden rounded-xl" onClick={() => openImageLightbox(message)}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={media.blobUrl} alt={fileName} className="max-h-72 max-w-full object-cover" />
</button>
);
}
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 ? (
<ChatSelectionContextMenu
count={selectedMessageIds.length}
canPin={selectedMessageIds.length === 1}
pinned={getSelectedMessages()[0]?.isPinned}
onReply={handleSelectionReply}
onCopy={() => void handleBulkCopy()}
onForward={() => setForwardDialogOpen(true)}
onDelete={() => void handleBulkDelete()}
onTogglePin={handleSelectionPin}
onCancel={exitSelectionMode}
>
<SelectableMessageWrapper
selected={isSelected}
selectionMode
align={mine ? 'right' : 'left'}
onClick={() => toggleSelectedMessage(message.id)}
>
{bubble}
</SelectableMessageWrapper>
</ChatSelectionContextMenu>
) : (
<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);
}}
onTogglePin={() => void handleTogglePin(message)}
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-2 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] sm:px-3 sm: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="hidden h-10 w-10 rounded-xl p-0 sm:flex" 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>
)}
</>
) : (
<div className="flex flex-1 items-center justify-center text-[#667085]">Выберите чат</div>
)}
</section>
<input
ref={fileInputRef}
type="file"
className="hidden"
multiple
accept="*/*"
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
if (files.length) queueMediaFiles(files);
event.target.value = '';
}}
/>
<ChatMediaComposeDialog
open={mediaComposer.open}
items={mediaComposer.items}
caption={mediaComposer.caption}
sendAsFile={mediaComposer.sendAsFile}
hasImages={mediaComposer.hasImages}
maxFiles={mediaComposer.maxFiles}
sending={sending}
onCaptionChange={mediaComposer.setCaption}
onSendAsFileChange={mediaComposer.setSendAsFile}
onRemoveItem={mediaComposer.removeItem}
onAddFiles={(files) => void mediaComposer.addFiles(files)}
onClose={mediaComposer.close}
onSend={() => void submitMediaCompose()}
/>
<ChatImageLightbox
open={lightboxIndex !== null}
images={chatImageMessages}
index={lightboxIndex ?? 0}
mediaUrls={mediaUrls}
onClose={() => setLightboxIndex(null)}
onIndexChange={setLightboxIndex}
onDownload={(message) => void downloadChatFile(message)}
/>
<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}
authToken={token}
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 canKick = !isSelf && isFamilyOwner && member.role !== 'owner' && !member.isBot;
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}
{canKick ? (
<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);
}}
>
Удалить
</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 && otherHumanMembers.length > 0 ? (
<Button
className="mt-2 w-full rounded-xl"
variant="secondary"
onClick={() => {
setFamilyMembersOpen(false);
openTransferOwnershipDialog();
}}
>
<ArrowRightLeft className="mr-2 h-4 w-4" />
Передать управление
</Button>
) : null}
<Button
className="mt-2 w-full rounded-xl"
variant="secondary"
onClick={() => {
setFamilyMembersOpen(false);
openLeaveFamilyDialog();
}}
>
<LogOut 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={transferOwnershipDialogOpen} onOpenChange={setTransferOwnershipDialogOpen}>
<DialogContent className="max-h-[90dvh] overflow-y-auto rounded-[28px] sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Передать управление семьёй</DialogTitle>
</DialogHeader>
<p className="text-sm leading-relaxed text-[#667085]">
Выберите участника, который станет новым главой семьи «{group?.name}». Вы останетесь участником и сможете
покинуть семью в любой момент.
</p>
<div className="mt-4 max-h-[280px] space-y-2 overflow-y-auto">
{otherHumanMembers.map((member) => {
const selected = selectedNewOwnerUserId === member.userId;
return (
<button
key={member.id}
type="button"
onClick={() => setSelectedNewOwnerUserId(member.userId)}
className={cn(
'flex w-full items-center gap-3 rounded-2xl px-3 py-3 text-left transition',
selected ? 'bg-[#3390ec]/10 ring-2 ring-[#3390ec]/40' : 'bg-[#f4f5f8] hover:bg-[#eceef4]'
)}
>
<AvatarWithPresence
userId={member.userId}
displayName={member.displayName}
hasAvatar={member.hasAvatar}
token={token}
isVerified={member.isVerified}
verificationIcon={member.verificationIcon}
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]">{familyMemberRoleLabel(member.role)}</p>
</div>
{selected ? <Check className="h-5 w-5 shrink-0 text-[#3390ec]" /> : null}
</button>
);
})}
</div>
<div className="mt-6 flex flex-col-reverse gap-3 sm:flex-row">
<Button
variant="secondary"
className="flex-1"
disabled={transferringOwnership}
onClick={() => setTransferOwnershipDialogOpen(false)}
>
Отмена
</Button>
<Button
className="flex-1"
disabled={transferringOwnership || !selectedNewOwnerUserId}
onClick={() => void confirmTransferOwnership()}
>
{transferringOwnership ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Передаём...
</>
) : (
'Передать управление'
)}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={leaveFamilyDialogOpen} onOpenChange={setLeaveFamilyDialogOpen}>
<DialogContent className="rounded-[28px] sm:max-w-[480px]">
<DialogHeader>
<DialogTitle>Покинуть семью «{group?.name}»?</DialogTitle>
</DialogHeader>
{ownerMustTransfer ? (
<>
<p className="text-sm leading-relaxed text-[#667085]">
Вы создатель семьи. Перед выходом передайте управление другому участнику он станет новым главой
семьи.
</p>
<div className="mt-4 max-h-[280px] space-y-2 overflow-y-auto">
{otherHumanMembers.map((member) => {
const selected = selectedNewOwnerUserId === member.userId;
return (
<button
key={member.id}
type="button"
onClick={() => setSelectedNewOwnerUserId(member.userId)}
className={cn(
'flex w-full items-center gap-3 rounded-2xl px-3 py-3 text-left transition',
selected ? 'bg-[#3390ec]/10 ring-2 ring-[#3390ec]/40' : 'bg-[#f4f5f8] hover:bg-[#eceef4]'
)}
>
<AvatarWithPresence
userId={member.userId}
displayName={member.displayName}
hasAvatar={member.hasAvatar}
token={token}
isVerified={member.isVerified}
verificationIcon={member.verificationIcon}
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]">{familyMemberRoleLabel(member.role)}</p>
</div>
{selected ? <Check className="h-5 w-5 shrink-0 text-[#3390ec]" /> : null}
</button>
);
})}
</div>
</>
) : ownerLeaveDeletesFamily ? (
<p className="text-sm leading-relaxed text-[#667085]">
В семье больше нет других участников. Семья и все чаты будут полностью удалены без возможности
восстановления.
</p>
) : (
<p className="text-sm leading-relaxed text-[#667085]">
Семья исчезнет из вашего профиля вместе со всеми чатами. У остальных участников переписка с вами
сохранится с пометкой, что вы больше не состоите в семье.
</p>
)}
<div className="mt-6 flex gap-3">
<Button
variant="secondary"
className="flex-1"
disabled={leavingFamily}
onClick={() => setLeaveFamilyDialogOpen(false)}
>
Отмена
</Button>
<Button
className="flex-1 bg-red-600 hover:bg-red-700"
disabled={leavingFamily || (ownerMustTransfer && !selectedNewOwnerUserId)}
onClick={() => void confirmLeaveFamily()}
>
{leavingFamily ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Выходим...
</>
) : (
'Покинуть семью'
)}
</Button>
</div>
</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' || activeRoom.type === 'BOT') &&
member.userId !== user?.id &&
member.userId !== activeRoom.peerUserId &&
!(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}
{member.inFamily === false ? (
<span className="ml-1.5 rounded-md bg-[#eceef4] px-1.5 py-0.5 text-[10px] font-medium text-[#667085]">
не в семье
</span>
) : null}
</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' || activeRoom?.type === 'BOT') && 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>
);
}