Files
IdP/apps/frontend/components/family/mini-family-chat.tsx
2026-06-26 13:01:52 +03:00

1316 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Link from 'next/link';
import { ArrowLeft, BarChart3, CalendarDays, ImageIcon, Loader2, MessageCircle, Paperclip, Pin, Search, Send, Settings, Smile, Trash2, X } from 'lucide-react';
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { FamilyBotChat, BotMiniAppSheet } from '@/components/family/family-bot-chat';
import { ChatRoomAvatarDisplay } from '@/components/family/chat-room-avatar-display';
import { ChatDateSeparator } from '@/components/chat/chat-date-separator';
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
import { ChatDeleteMessageDialog } from '@/components/chat/chat-delete-message-dialog';
import { ChatMessageEditBanner } from '@/components/chat/chat-message-edit-banner';
import { ChatMessageReactions } from '@/components/chat/chat-message-reactions';
import { ChatSelectionToolbar } from '@/components/chat/chat-selection-toolbar';
import { ChatToolsDialog, type ChatToolsTab } from '@/components/chat/chat-tools-dialog';
import { EmojiPicker } from '@/components/chat/emoji-picker';
import { scrollToChatMessage } from '@/components/chat/replied-message-preview';
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
import { useAuth } from '@/components/id/auth-provider';
import { UserAvatar } from '@/components/id/user-avatar';
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 { useSelectedFamily } from '@/hooks/use-primary-family';
import { useChatDragSelection } from '@/hooks/use-chat-drag-selection';
import { useChatMessageSelection } from '@/hooks/use-chat-message-selection';
import { FamilyGroupSelector } from '@/components/family/family-group-selector';
import {
apiFetch,
BotChatMessage,
BotChatMeta,
ChatMessage,
ChatRoom,
deleteChatMessage,
deleteChatRoom,
editChatMessage,
fetchBotChatMessages,
fetchChatMessages,
fetchChatRooms,
getApiErrorMessage,
markChatRoomRead,
sendBotMessage,
sendChatMessage,
setChatMessagePinned,
toggleChatMessageReaction,
uploadMediaObject,
voteChatPoll
} from '@/lib/api';
import {
getChatListPreviewText,
getMessageCopyText,
getMessagePreviewText,
isChatInteractiveTarget,
isSystemMessage
} from '@/lib/chat-message-utils';
import { detectChatMessageType, parseMessageMetadata, resolveChatContentType } from '@/lib/chat-media';
import { getSingleEmojiNative, isSingleEmojiMessage } from '@/lib/chat-emoji-display';
import { shouldShowChatDateSeparator } from '@/lib/chat-date-utils';
import { encryptE2EBlob, e2eKindFromMessageType } from '@/lib/e2e-chat';
import { findMemberRoom, findOrCreateMemberChat, roomDisplayLabel } from '@/lib/family-chat';
import { getE2EMessageText, useE2EChat } from '@/hooks/use-e2e-chat';
import { cn } from '@/lib/utils';
interface PresignedUploadResponse {
uploadUrl: string;
storageKey: string;
expiresAt?: string;
uploadToken?: string;
}
function formatMessageTime(value: string) {
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(value));
}
export function MiniFamilyChat() {
const { user, token, isPinLocked } = useAuth();
const { showToast } = useToast();
const { subscribe } = useRealtime();
const {
group,
groupSummaries,
hasFamily,
selectedGroupId,
setSelectedGroupId,
presenceByUserId
} = useSelectedFamily(Boolean(user && token && !isPinLocked));
const {
miniChatOpen,
openMiniChat,
closeMiniChat,
pendingRoomId,
pendingMember,
clearPending
} = useFamilyOverlay();
const [view, setView] = useState<'picker' | 'chat' | 'bot'>('picker');
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [activeRoom, setActiveRoom] = useState<ChatRoom | null>(null);
const [activeBot, setActiveBot] = useState<{ botRef: string; name: string } | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [botMessages, setBotMessages] = useState<BotChatMessage[]>([]);
const [draft, setDraft] = useState('');
const [loadingRooms, setLoadingRooms] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [sending, setSending] = useState(false);
const [openingMemberId, setOpeningMemberId] = useState<string | null>(null);
const [deletingRoomId, setDeletingRoomId] = useState<string | null>(null);
const [miniAppUrl, setMiniAppUrl] = useState<string | null>(null);
const [miniAppTitle, setMiniAppTitle] = useState('Mini App');
const [botChatMeta, setBotChatMeta] = useState<BotChatMeta | null>(null);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
const [deleteMessagesDialogOpen, setDeleteMessagesDialogOpen] = useState(false);
const [pendingDeleteMessageIds, setPendingDeleteMessageIds] = useState<string[]>([]);
const [deletingMessages, setDeletingMessages] = useState(false);
const [emojiOpen, setEmojiOpen] = useState(false);
const [pollOpen, setPollOpen] = useState(false);
const [pollQuestion, setPollQuestion] = useState('');
const [pollOptions, setPollOptions] = useState(['', '']);
const [chatToolsOpen, setChatToolsOpen] = useState(false);
const [chatToolsTab, setChatToolsTab] = useState<ChatToolsTab>('search');
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const previousGroupIdRef = useRef<string | null>(null);
const visibleMessages = useMemo(
() => messages.filter((message) => !message.isDeleted),
[messages]
);
const {
selectionMode,
selectedMessageIds,
enterSelectionMode,
exitSelectionMode,
toggleSelectedMessage,
addSelectedMessage,
handleSelectionPointer
} = useChatMessageSelection(visibleMessages);
const { handleMessagePointerDown, handleDragEnterMessage, isDragging } = useChatDragSelection({
selectionMode,
enterSelectionMode,
addSelectedMessage
});
const { peerE2eKey, peerKeyLoading, e2ePayloads, e2eErrors, encryptOutgoing, isE2E: activeRoomIsE2E } = useE2EChat({
room: activeRoom,
messages,
userId: user?.id,
token
});
const loadRooms = useCallback(async () => {
if (!group || !token) return;
setLoadingRooms(true);
try {
const response = await fetchChatRooms(group.id, token);
setRooms(response.rooms ?? []);
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось загрузить чаты') ?? 'Ошибка');
} finally {
setLoadingRooms(false);
}
}, [group, showToast, token]);
const openBotChat = useCallback(
async (botRef: string, botName: string) => {
if (!token) return;
setActiveBot({ botRef, name: botName });
setActiveRoom(null);
setView('bot');
setLoadingMessages(true);
try {
const response = await fetchBotChatMessages(botRef, token);
setBotMessages(response.messages ?? []);
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось загрузить чат с ботом') ?? 'Ошибка');
} finally {
setLoadingMessages(false);
}
},
[showToast, token]
);
const openRoom = useCallback(
async (room: ChatRoom) => {
if (room.type === 'BOT' && room.botUsername && user) {
await openBotChat(room.botUsername, roomDisplayLabel(room, user.id));
return;
}
setActiveRoom(room);
setActiveBot(null);
setView('chat');
setLoadingMessages(true);
try {
const response = await fetchChatMessages(room.id, token);
setMessages((response.messages ?? []).filter((message) => !message.isDeleted));
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось загрузить сообщения') ?? 'Ошибка');
} finally {
setLoadingMessages(false);
}
},
[openBotChat, showToast, token, user]
);
const openMemberChat = useCallback(
async (memberUserId: string, memberName: string, options?: { isBot?: boolean; botUsername?: string }) => {
if (!group || !token || !user) return;
if (options?.isBot && options.botUsername) {
await openBotChat(options.botUsername, memberName);
return;
}
const existing = findMemberRoom(rooms, memberUserId, user.id, options);
if (existing) {
await openRoom(existing);
return;
}
setOpeningMemberId(memberUserId);
try {
const room = await findOrCreateMemberChat(group.id, memberUserId, memberName, user.id, token);
await loadRooms();
await openRoom(room);
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось открыть чат') ?? 'Ошибка');
} finally {
setOpeningMemberId(null);
}
},
[group, loadRooms, openBotChat, openRoom, rooms, showToast, token, user]
);
useEffect(() => {
if (!miniChatOpen || !hasFamily) return;
void loadRooms();
}, [group?.id, hasFamily, loadRooms, miniChatOpen]);
useEffect(() => {
if (!group?.id) return;
if (previousGroupIdRef.current && previousGroupIdRef.current !== group.id && miniChatOpen) {
setView('picker');
setActiveRoom(null);
setActiveBot(null);
setMessages([]);
setBotMessages([]);
setDraft('');
}
previousGroupIdRef.current = group.id;
}, [group?.id, miniChatOpen]);
useEffect(() => {
if (!miniChatOpen) {
setView('picker');
setActiveRoom(null);
setActiveBot(null);
setMessages([]);
setBotMessages([]);
setDraft('');
exitSelectionMode();
setEmojiOpen(false);
setPollOpen(false);
clearPending();
return;
}
if (pendingRoomId && rooms.length) {
const room = rooms.find((item) => item.id === pendingRoomId);
if (room) {
void openRoom(room);
clearPending();
}
} else if (pendingMember && user) {
void openMemberChat(pendingMember.userId, pendingMember.name, {
isBot: pendingMember.isBot,
botUsername: pendingMember.botUsername
});
clearPending();
}
}, [clearPending, exitSelectionMode, miniChatOpen, openMemberChat, openRoom, pendingMember, pendingRoomId, rooms, user]);
useEffect(() => {
exitSelectionMode();
setEditingMessageId(null);
setDraft('');
setEmojiOpen(false);
}, [activeRoom?.id, exitSelectionMode]);
useEffect(() => {
if (!activeBot) return;
return subscribe((event) => {
if (event.type !== 'bot_message' && event.type !== 'bot_message_edited') return;
const payload = event.payload as {
text?: string | null;
messageId?: number;
messageType?: string;
editedAt?: string;
};
if (!payload?.messageId) return;
setBotMessages((current) => {
if (event.type === 'bot_message_edited') {
return current.map((item) =>
item.messageId === payload.messageId
? { ...item, text: payload.text ?? item.text, createdAt: payload.editedAt ?? item.createdAt }
: item
);
}
const exists = current.some((item) => item.messageId === payload.messageId && item.direction === 'out');
if (exists) return current;
return [
...current,
{
id: `out-live-${payload.messageId}`,
direction: 'out' as const,
text: payload.text ?? '',
messageType: payload.messageType ?? 'text',
messageId: payload.messageId ?? 0,
createdAt: new Date().toISOString()
}
];
});
});
}, [activeBot, subscribe]);
useEffect(() => {
if (!activeRoom) return;
return subscribe((event) => {
const payload = event.payload;
if (payload?.roomId && payload.roomId !== activeRoom.id) return;
if (event.type === 'chat_message' && payload?.message) {
const message = payload.message as ChatMessage;
if (message.isDeleted) return;
setMessages((current) => {
if (current.some((item) => item.id === message.id)) return current;
return [...current, message];
});
}
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));
} else {
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
}
}
}
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));
}
}
});
}, [activeRoom, subscribe]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
if (!activeRoom || !token || !messages.length) return;
const last = messages[messages.length - 1];
const timer = window.setTimeout(() => {
void markChatRoomRead(activeRoom.id, last.id, token).catch(() => {});
}, 400);
return () => window.clearTimeout(timer);
}, [activeRoom, messages, token]);
function cancelEditMessage() {
setEditingMessageId(null);
setDraft('');
}
function requestDeleteMessages(messageIds: string[]) {
if (!messageIds.length) return;
setPendingDeleteMessageIds(messageIds);
setDeleteMessagesDialogOpen(true);
}
async function confirmDeleteMessages() {
if (!token || !pendingDeleteMessageIds.length) return;
const ids = [...pendingDeleteMessageIds];
setDeletingMessages(true);
setMessages((current) => current.filter((item) => !ids.includes(item.id)));
try {
for (const messageId of ids) {
await deleteChatMessage(messageId, token);
if (editingMessageId === messageId) cancelEditMessage();
}
setDeleteMessagesDialogOpen(false);
setPendingDeleteMessageIds([]);
exitSelectionMode();
showToast(ids.length === 1 ? 'Сообщение удалено' : 'Сообщения удалены');
} catch (error) {
if (view === 'bot' && activeBot) {
const response = await fetchBotChatMessages(activeBot.botRef, token);
setBotMessages(response.messages ?? []);
} else if (activeRoom && activeRoom.type !== 'BOT') {
const response = await fetchChatMessages(activeRoom.id, token);
setMessages((response.messages ?? []).filter((message) => !message.isDeleted));
}
showToast(getApiErrorMessage(error, 'Не удалось удалить сообщение') ?? 'Ошибка');
} finally {
setDeletingMessages(false);
}
}
async function handleSend() {
if (!token || !draft.trim()) return;
if (view === 'bot' && activeBot) {
setSending(true);
try {
const text = draft.trim();
setBotMessages((current) => [
...current,
{
id: `in-local-${Date.now()}`,
direction: 'in',
text,
messageType: 'text',
messageId: 0,
createdAt: new Date().toISOString()
}
]);
setDraft('');
await sendBotMessage(activeBot.botRef, text, token);
const response = await fetchBotChatMessages(activeBot.botRef, token);
setBotMessages(response.messages ?? []);
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить сообщение') ?? 'Ошибка');
} finally {
setSending(false);
}
return;
}
if (!activeRoom) return;
if (editingMessageId) {
setSending(true);
try {
const updated = await editChatMessage(editingMessageId, draft.trim(), token);
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
cancelEditMessage();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось изменить сообщение') ?? 'Ошибка');
} finally {
setSending(false);
}
return;
}
setSending(true);
try {
let content = draft.trim();
let isEncrypted = false;
if (activeRoomIsE2E) {
content = await encryptOutgoing({ kind: 'text', text: content });
isEncrypted = true;
}
const message = await sendChatMessage(activeRoom.id, { type: 'TEXT', content, isEncrypted }, token);
setMessages((current) => [...current, message]);
setDraft('');
void loadRooms();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить сообщение') ?? 'Ошибка');
} finally {
setSending(false);
}
}
function insertEmojiToDraft(nativeEmoji: string) {
setDraft((current) => `${current}${nativeEmoji}`);
}
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 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;
}
showToast('Ответ доступен в полном виде семьи');
}
function handleSelectionPin() {
const selected = getSelectedMessages();
if (selected.length !== 1) {
showToast('Закрепить можно только одно сообщение');
return;
}
void handleTogglePin(selected[0]!);
}
async function uploadChatFile(file: File) {
if (!token || !activeRoom || sending) return;
setSending(true);
try {
const contentType = resolveChatContentType(file);
const messageType = detectChatMessageType(file);
const metadata = { fileName: file.name, fileSize: file.size };
let uploadFile = file;
let uploadContentType = contentType;
let messageContent: string | undefined = 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
});
isEncrypted = true;
}
const presigned = await apiFetch<PresignedUploadResponse>(
`/media/chat/${activeRoom.id}/media/upload-url`,
{
method: 'POST',
body: JSON.stringify({ contentType: uploadContentType, fileName: uploadFile.name })
},
token
);
await uploadMediaObject(presigned, uploadFile, uploadContentType);
const message = await sendChatMessage(
activeRoom.id,
{
type: messageType,
storageKey: presigned.storageKey,
mimeType: uploadContentType,
content: messageContent,
isEncrypted,
metadataJson: JSON.stringify(activeRoomIsE2E ? { e2e: true, ...metadata } : metadata)
},
token
);
setMessages((current) => [...current, message]);
void loadRooms();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить файл') ?? 'Ошибка');
} finally {
setSending(false);
}
}
async function submitPoll() {
if (!token || !activeRoom) 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(
activeRoom.id,
{ type: 'POLL', poll: { question: pollQuestion.trim(), options } },
token
);
setMessages((current) => [...current, message]);
setPollOpen(false);
setPollQuestion('');
setPollOptions(['', '']);
void loadRooms();
} 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 (activeRoom?.id === roomId) {
setActiveRoom(null);
setMessages([]);
setView('picker');
}
await loadRooms();
showToast('Чат удалён');
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось удалить чат') ?? 'Ошибка');
} finally {
setDeletingRoomId(null);
}
}
const otherMembers = useMemo(
() => (group?.members ?? []).filter((member) => member.userId !== user?.id),
[group?.members, user?.id]
);
if (!user || !token || isPinLocked) return null;
return (
<>
<button
type="button"
onClick={() => (miniChatOpen ? closeMiniChat() : openMiniChat())}
className={cn(
'fixed bottom-5 right-5 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-[#3390ec] text-white shadow-lg transition hover:bg-[#2b7fd4] lg:bottom-6 lg:right-6',
miniChatOpen && 'scale-95'
)}
aria-label={miniChatOpen ? 'Закрыть чат семьи' : 'Открыть чат семьи'}
>
{miniChatOpen ? <X className="h-5 w-5" /> : <MessageCircle className="h-5 w-5" />}
</button>
{miniChatOpen ? (
<div className="fixed bottom-20 right-5 z-50 flex w-[min(92vw,380px)] flex-col overflow-hidden rounded-[24px] border border-[#eceef4] bg-white shadow-2xl lg:bottom-[5.5rem] lg:right-6">
<div className="flex items-center gap-2 border-b border-[#eceef4] px-4 py-3">
{view === 'chat' || view === 'bot' ? (
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={() => setView('picker')}>
<ArrowLeft className="h-4 w-4" />
</Button>
) : null}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">
{view === 'bot' && activeBot
? activeBot.name
: view === 'chat' && activeRoom && user
? roomDisplayLabel(activeRoom, user.id)
: 'Чат семьи'}
</p>
{view === 'chat' && activeRoom && user ? (
<div className="mt-1 flex items-center gap-2">
<ChatRoomAvatarDisplay room={activeRoom} viewerUserId={user.id} token={token} size="sm" />
<span className="truncate text-xs text-[#667085]">{activeRoom.members.length} участников</span>
</div>
) : null}
{hasFamily ? (
<FamilyGroupSelector
groups={groupSummaries}
selectedGroupId={selectedGroupId}
onSelect={setSelectedGroupId}
href={`/family/${group!.id}`}
nameClassName="text-xs text-[#667085]"
/>
) : (
<p className="text-xs text-[#667085]">Семья не создана</p>
)}
</div>
{view === 'bot' && botChatMeta?.manageWebAppUrl && user?.id === botChatMeta.botOwnerId ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label="Настройки бота"
title="Настройки бота"
onClick={() => {
setMiniAppTitle('Настройки бота');
setMiniAppUrl(botChatMeta.manageWebAppUrl!);
}}
>
<Settings className="h-4 w-4" />
</Button>
) : null}
{view === 'chat' && activeRoom ? (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label="Поиск по сообщениям"
onClick={() => {
setChatToolsTab('search');
setChatToolsOpen(true);
}}
>
<Search className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label="Перейти к дате"
onClick={() => {
setChatToolsTab('calendar');
setChatToolsOpen(true);
}}
>
<CalendarDays className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
aria-label="Медиа чата"
onClick={() => {
setChatToolsTab('media');
setChatToolsOpen(true);
}}
>
<ImageIcon className="h-4 w-4" />
</Button>
</>
) : null}
{view === 'chat' && activeRoom && activeRoom.type !== 'GENERAL' ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-red-600"
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}
<Button type="button" variant="ghost" size="icon" className="h-8 w-8 shrink-0" onClick={closeMiniChat}>
<X className="h-4 w-4" />
</Button>
</div>
{!hasFamily ? (
<div className="space-y-3 p-5 text-sm text-[#667085]">
<p>Создайте семейную группу, чтобы общаться с близкими из любого раздела.</p>
<Link href="/family" className="inline-flex text-[#3390ec] hover:underline" onClick={closeMiniChat}>
Перейти к созданию семьи
</Link>
</div>
) : view === 'picker' ? (
<div className="max-h-[min(60vh,460px)] overflow-y-auto p-3">
<section className="mb-4">
<p className="mb-2 px-1 text-xs font-semibold uppercase tracking-wide text-[#667085]">Чаты</p>
{loadingRooms ? (
<div className="flex items-center gap-2 px-2 py-3 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка...
</div>
) : rooms.length ? (
<div className="space-y-1">
{rooms.map((room) => (
<div key={room.id} className="group flex items-stretch rounded-xl transition hover:bg-[#f4f5f8]">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 px-2 py-2 text-left"
onClick={() => void openRoom(room)}
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full">
{user ? (
<ChatRoomAvatarDisplay room={room} viewerUserId={user.id} token={token} size="sm" />
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[#eef4ff] text-xs font-semibold text-[#3390ec]">
{room.type === 'GENERAL' ? 'В' : room.name.slice(0, 2).toUpperCase()}
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{user ? roomDisplayLabel(room, user.id) : room.name}</p>
<p className="truncate text-xs text-[#667085]">
{getChatListPreviewText(room.lastMessage, 'Нет сообщений')}
</p>
</div>
</button>
{room.type !== 'GENERAL' ? (
<button
type="button"
aria-label="Удалить чат"
disabled={deletingRoomId === room.id}
className="mr-1 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-3.5 w-3.5" />}
</button>
) : null}
</div>
))}
</div>
) : (
<p className="px-2 py-2 text-sm text-[#667085]">Чаты пока не созданы</p>
)}
</section>
<section>
<p className="mb-2 px-1 text-xs font-semibold uppercase tracking-wide text-[#667085]">Написать участнику</p>
<div className="space-y-1">
{otherMembers.map((member) => {
const presence = presenceByUserId.get(member.userId);
return (
<button
key={member.id}
type="button"
disabled={openingMemberId === member.userId}
className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-left transition hover:bg-[#f4f5f8] disabled:opacity-60"
onClick={() =>
void openMemberChat(member.userId, member.displayName, {
isBot: member.isBot,
botUsername: member.botUsername
})
}
>
<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 className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{member.displayName}
{member.isBot ? ' 🤖' : ''}
</p>
<p className={cn('text-xs', member.isBot ? 'text-[#3390ec]' : presence?.online ? 'text-emerald-600' : 'text-[#667085]')}>
{member.isBot ? 'Бот' : presence?.online ? 'В сети' : 'Личный чат'}
</p>
</div>
{openingMemberId === member.userId ? <Loader2 className="h-4 w-4 animate-spin text-[#667085]" /> : null}
</button>
);
})}
{!otherMembers.length ? (
<p className="px-2 py-2 text-sm text-[#667085]">Пригласите участников в семью</p>
) : null}
</div>
</section>
<Link
href={`/family/${group!.id}`}
className="mt-3 block rounded-xl bg-[#f4f5f8] px-3 py-2 text-center text-xs font-medium text-[#3390ec] hover:bg-[#eceef4]"
onClick={closeMiniChat}
>
Открыть семью полностью
</Link>
</div>
) : view === 'bot' && activeBot ? (
<FamilyBotChat
botRef={activeBot.botRef}
botName={activeBot.name}
token={token}
className="max-h-[min(60vh,460px)]"
onBotMetaChange={setBotChatMeta}
onOpenMiniApp={(url) => {
setMiniAppTitle('Mini App');
setMiniAppUrl(url);
}}
/>
) : (
<>
<div
className={cn('flex-1 space-y-2 overflow-y-auto bg-[#eef1f6] p-3', isDragging && 'select-none')}
style={{ maxHeight: 'min(50vh,380px)' }}
>
{loadingMessages ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-[#667085]">
<Loader2 className="h-4 w-4 animate-spin" />
Загрузка...
</div>
) : visibleMessages.length ? (
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 py-1">
<p className="rounded-full bg-black/[0.06] px-3 py-1 text-[11px] 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 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(
'max-w-[85%] rounded-[16px] px-3 py-2 text-sm shadow-sm',
mine ? 'bg-[#effdde]' : 'bg-white',
!selectionMode && 'max-w-[85%]'
)
)}
>
{message.isPinned && !isLargeEmoji ? (
<div className={cn('mb-1 flex items-center gap-1 text-[10px] font-medium', mine ? 'text-[#6c8f56]' : 'text-[#3390ec]')}>
<Pin className="h-3 w-3" />
Закреплено
</div>
) : null}
{!mine && !isLargeEmoji ? (
<p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{message.senderName}</p>
) : null}
{message.type === 'POLL' && message.poll ? (
<div className="space-y-1.5">
<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"
data-chat-interactive="true"
className="relative w-full overflow-hidden rounded-lg border border-[#dce3ec] px-2 py-1.5 text-left text-xs"
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 ? (
<p className="text-sm">{getMessagePreviewText(message, visibleText)}</p>
) : isLargeEmoji ? (
<div className="relative inline-flex min-w-[64px] flex-col items-center">
<span className="text-[48px] leading-none">{largeEmojiNative}</span>
<div className="absolute bottom-0 right-0 rounded-full bg-black/45 px-1.5 py-0.5 text-[10px] text-white">
{formatMessageTime(message.createdAt)}
</div>
</div>
) : emojiContent ? (
<div className="py-0.5">
<ChatEmojiContent content={emojiContent} size={28} />
</div>
) : (
<div className="whitespace-pre-wrap break-words text-sm leading-relaxed">
<ChatEmojiContent content={visibleText} size={18} />
</div>
)}
{!isLargeEmoji ? (
<p className="mt-1 text-[10px] text-[#a8adbc]">{formatMessageTime(message.createdAt)}</p>
) : null}
{!isLargeEmoji ? (
<ChatMessageReactions
reactions={message.reactions}
onToggle={(emoji) => {
if (!token) return;
void toggleChatMessageReaction(message.id, emoji, token).then((updated) => {
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
});
}}
compact
/>
) : null}
</div>
);
return (
<div key={message.id}>
{showDateSeparator ? <ChatDateSeparator date={message.createdAt} /> : null}
<div
id={`msg-${message.id}`}
className={cn('group flex gap-1.5', 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-2 -mt-0.5 h-7 w-7 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={() => showToast('Пересылка доступна в полном виде семьи')}
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={() => {
setEditingMessageId(message.id);
setDraft(message.content ?? '');
}}
onDelete={() => requestDeleteMessages([message.id])}
onForward={() => showToast('Пересылка доступна в полном виде семьи')}
onTogglePin={() => void handleTogglePin(message)}
onSelect={() => enterSelectionMode(message.id)}
onToggleReaction={async (emoji) => {
if (!token) return;
const updated = await toggleChatMessageReaction(message.id, emoji, token);
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
}}
onCopy={() => showToast('Текст скопирован')}
>
{bubble}
</ChatMessageContextMenu>
)}
{mine ? (
<UserAvatar
userId={user.id}
displayName={user.displayName}
hasAvatar={Boolean(user.hasAvatar)}
token={token}
isVerified={user.isVerified}
verificationIcon={user.verificationIcon}
className="z-10 -ml-2 -mt-0.5 h-7 w-7 shrink-0 self-start ring-2 ring-[#eef1f6]"
badgeSize="xs"
/>
) : null}
</div>
</div>
);
})
) : (
<p className="py-8 text-center text-sm text-[#667085]">Сообщений пока нет</p>
)}
<div ref={messagesEndRef} />
</div>
{selectionMode ? (
<ChatSelectionToolbar
count={selectedMessageIds.length}
deleting={deletingMessages}
onCancel={exitSelectionMode}
onCopy={() => void handleBulkCopy()}
onForward={() => showToast('Пересылка доступна в полном виде семьи')}
onDelete={() => void handleBulkDelete()}
/>
) : (
<div className="flex flex-col gap-2 border-t border-[#eceef4] p-3">
{editingMessageId ? (
<ChatMessageEditBanner
preview={draft.trim() || messages.find((item) => item.id === editingMessageId)?.content || undefined}
onCancel={cancelEditMessage}
/>
) : null}
{emojiOpen ? (
<div className="mb-1">
<EmojiPicker className="w-full" onSelect={(nativeEmoji) => insertEmojiToDraft(nativeEmoji)} />
</div>
) : null}
<div className="flex items-end gap-1">
<Button
type="button"
size="icon"
variant="ghost"
className="h-9 w-9 shrink-0 rounded-xl"
onClick={() => fileInputRef.current?.click()}
disabled={Boolean(editingMessageId)}
>
<Paperclip className="h-4 w-4" />
</Button>
{!activeRoomIsE2E ? (
<Button
type="button"
size="icon"
variant="ghost"
className="h-9 w-9 shrink-0 rounded-xl"
onClick={() => setPollOpen(true)}
disabled={Boolean(editingMessageId)}
>
<BarChart3 className="h-4 w-4" />
</Button>
) : null}
<Button
type="button"
size="icon"
variant="ghost"
className="h-9 w-9 shrink-0 rounded-xl"
onClick={() => setEmojiOpen((value) => !value)}
disabled={Boolean(editingMessageId)}
>
<Smile className="h-4 w-4" />
</Button>
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder={editingMessageId ? 'Измените сообщение' : 'Сообщение'}
className="min-h-[40px] flex-1 rounded-xl"
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSend();
}
if (event.key === 'Escape') {
if (editingMessageId) {
cancelEditMessage();
}
}
}}
/>
<Button
className="h-9 w-9 shrink-0 rounded-full p-0"
disabled={sending || !draft.trim()}
onClick={() => void handleSend()}
>
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
</div>
)}
</>
)}
</div>
) : null}
<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]
})
)
}
/>
<ChatDeleteMessageDialog
open={deleteMessagesDialogOpen}
count={pendingDeleteMessageIds.length}
loading={deletingMessages}
onOpenChange={(open) => {
setDeleteMessagesDialogOpen(open);
if (!open) {
setPendingDeleteMessageIds([]);
exitSelectionMode();
}
}}
onConfirm={() => void confirmDeleteMessages()}
/>
<Dialog open={pollOpen} onOpenChange={setPollOpen}>
<DialogContent className="rounded-[24px] sm:max-w-[360px]">
<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 w-full rounded-xl" onClick={() => setPollOptions((current) => [...current, ''])}>
Добавить вариант
</Button>
<Button className="mt-3 w-full rounded-xl" disabled={sending} onClick={() => void submitPoll()}>
{sending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
Отправить опрос
</Button>
</DialogContent>
</Dialog>
<input
ref={fileInputRef}
type="file"
accept="image/*,audio/*,video/*,.pdf,.doc,.docx,.txt,.zip"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void uploadChatFile(file);
event.target.value = '';
}}
/>
<BotMiniAppSheet
url={miniAppUrl}
title={miniAppTitle}
authToken={token}
onClose={() => setMiniAppUrl(null)}
/>
</>
);
}