Files
IdP/apps/frontend/components/family/mini-family-chat.tsx
2026-06-29 12:17:25 +03:00

1421 lines
59 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 { usePathname } from 'next/navigation';
import { ArrowLeft, BarChart3, CalendarDays, ImageIcon, Loader2, MessageCircle, Paperclip, 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 {
ChatMediaComposeDialog,
ChatMediaDropOverlay,
extractFilesFromClipboard,
extractFilesFromDataTransfer,
useChatMediaComposer
} from '@/components/chat/chat-media-compose-dialog';
import { ChatEmojiContent } from '@/components/chat/chat-emoji-content';
import { ChatMessageContextMenu, ChatSelectionContextMenu, SelectableMessageWrapper } from '@/components/chat/chat-message-context-menu';
import { ChatPinnedMessageBanner } from '@/components/chat/chat-pinned-message-banner';
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 { uploadChatMediaBatch } from '@/lib/chat-media-upload';
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 [mediaDropVisible, setMediaDropVisible] = useState(false);
const mediaComposer = useChatMediaComposer();
const messagesEndRef = useRef<HTMLDivElement>(null);
const autoScrollStateRef = useRef<{ id: string | null; count: number }>({ id: null, count: 0 });
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const previousGroupIdRef = useRef<string | null>(null);
const visibleMessages = useMemo(
() => messages.filter((message) => !message.isDeleted),
[messages]
);
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 {
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 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 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(() => {
autoScrollStateRef.current = { id: null, count: 0 };
}, [activeRoom?.id]);
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 (!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) {
queueMediaFiles([file]);
}
const queueMediaFiles = useCallback(
(files: File[], options?: { sendAsFile?: boolean }) => {
if (!activeRoom || view !== 'chat' || !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, mediaComposer, view]
);
async function submitMediaCompose() {
if (!token || !activeRoom || !mediaComposer.items.length) return;
setSending(true);
try {
const uploaded = await uploadChatMediaBatch({
roomId: activeRoom.id,
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
});
setMessages((current) => [...current, ...uploaded]);
mediaComposer.close();
void loadRooms();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить файлы') ?? 'Ошибка');
} finally {
setSending(false);
}
}
useEffect(() => {
if (!activeRoom || view !== 'chat') 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, queueMediaFiles, view]);
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]
);
const pathname = usePathname();
const onFamilyChatPage = Boolean(pathname && /^\/family\/[^/]+$/.test(pathname));
if (!user || !token || isPinLocked) return null;
if (onFamilyChatPage) return null;
return (
<>
<button
type="button"
onClick={() => (miniChatOpen ? closeMiniChat() : openMiniChat())}
className={cn(
'fixed bottom-[calc(4.5rem+env(safe-area-inset-bottom))] right-4 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-[calc(4.5rem+env(safe-area-inset-bottom)+3.5rem)] right-4 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="relative flex min-h-0 flex-1 flex-col"
onDragEnter={(event) => {
event.preventDefault();
dragDepthRef.current += 1;
setMediaDropVisible(true);
}}
onDragLeave={(event) => {
event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setMediaDropVisible(false);
}}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
dragDepthRef.current = 0;
setMediaDropVisible(false);
const files = extractFilesFromDataTransfer(event.dataTransfer);
if (files.length) queueMediaFiles(files, { sendAsFile: false });
}}
>
<ChatMediaDropOverlay
visible={mediaDropVisible}
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}
className="px-3 py-2"
onClick={() => {
if (!scrollToChatMessage(pinnedMessage.id)) {
showToast('Закреплённое сообщение пока не загружено в ленте');
}
}}
/>
) : null}
<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%]'
)
)}
>
{!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>
)}
</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"
multiple
accept="*/*"
className="hidden"
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()}
/>
<BotMiniAppSheet
url={miniAppUrl}
title={miniAppTitle}
authToken={token}
onClose={() => setMiniAppUrl(null)}
/>
</>
);
}