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

395 lines
16 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, Loader2, MessageCircle, Send, X } from 'lucide-react';
import { AvatarWithPresence } from '@/components/family/avatar-with-presence';
import { useFamilyOverlay } from '@/components/family/family-overlay-provider';
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 { Input } from '@/components/ui/input';
import { useSelectedFamily } from '@/hooks/use-primary-family';
import { FamilyGroupSelector } from '@/components/family/family-group-selector';
import {
ChatMessage,
ChatRoom,
fetchChatMessages,
fetchChatRooms,
getApiErrorMessage,
markChatRoomRead,
sendChatMessage
} from '@/lib/api';
import { findOrCreateMemberChat, roomDisplayLabel } from '@/lib/family-chat';
import { cn } from '@/lib/utils';
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'>('picker');
const [rooms, setRooms] = useState<ChatRoom[]>([]);
const [activeRoom, setActiveRoom] = useState<ChatRoom | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
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 messagesEndRef = useRef<HTMLDivElement>(null);
const previousGroupIdRef = useRef<string | null>(null);
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 openRoom = useCallback(
async (room: ChatRoom) => {
setActiveRoom(room);
setView('chat');
setLoadingMessages(true);
try {
const response = await fetchChatMessages(room.id, token);
setMessages(response.messages ?? []);
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось загрузить сообщения') ?? 'Ошибка');
} finally {
setLoadingMessages(false);
}
},
[showToast, token]
);
const openMemberChat = useCallback(
async (memberUserId: string, memberName: string) => {
if (!group || !token || !user) 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, openRoom, 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);
setMessages([]);
setDraft('');
}
previousGroupIdRef.current = group.id;
}, [group?.id, miniChatOpen]);
useEffect(() => {
if (!miniChatOpen) {
setView('picker');
setActiveRoom(null);
setMessages([]);
setDraft('');
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);
clearPending();
}
}, [clearPending, miniChatOpen, openMemberChat, openRoom, pendingMember, pendingRoomId, rooms, user]);
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;
setMessages((current) => {
if (current.some((item) => item.id === message.id)) return current;
return [...current, message];
});
}
if (event.type === 'chat_message_updated' || event.type === 'chat_message_deleted') {
const updated = payload?.message;
if (updated) {
setMessages((current) => current.map((item) => (item.id === updated.id ? updated : item)));
}
}
});
}, [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]);
async function handleSend() {
if (!token || !activeRoom || !draft.trim()) return;
setSending(true);
try {
const message = await sendChatMessage(activeRoom.id, { type: 'TEXT', content: draft.trim() }, token);
setMessages((current) => [...current, message]);
setDraft('');
void loadRooms();
} catch (error) {
showToast(getApiErrorMessage(error, 'Не удалось отправить сообщение') ?? 'Ошибка');
} finally {
setSending(false);
}
}
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' ? (
<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 === 'chat' && activeRoom && user ? roomDisplayLabel(activeRoom, user.id) : 'Чат семьи'}
</p>
{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>
<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) => (
<button
key={room.id}
type="button"
className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-left transition hover:bg-[#f4f5f8]"
onClick={() => void openRoom(room)}
>
<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 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]">
{room.lastMessage?.isDeleted
? 'Сообщение удалено'
: room.lastMessage?.content ?? 'Нет сообщений'}
</p>
</div>
</button>
))}
</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)}
>
<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}</p>
<p className={cn('text-xs', presence?.online ? 'text-emerald-600' : 'text-[#667085]')}>
{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>
) : (
<>
<div className="flex-1 space-y-2 overflow-y-auto bg-[#eef1f6] p-3" 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>
) : messages.length ? (
messages.map((message) => {
const mine = message.senderId === user.id;
return (
<div key={message.id} className={cn('flex', mine ? 'justify-end' : 'justify-start')}>
<div
className={cn(
'max-w-[85%] rounded-[16px] px-3 py-2 text-sm shadow-sm',
mine ? 'bg-[#effdde]' : 'bg-white',
message.isDeleted && 'italic text-[#667085]'
)}
>
{!mine ? <p className="mb-0.5 text-[11px] font-semibold text-[#3390ec]">{message.senderName}</p> : null}
<p>{message.isDeleted ? 'Сообщение удалено' : message.content ?? '…'}</p>
<p className="mt-1 text-[10px] text-[#a8adbc]">{formatMessageTime(message.createdAt)}</p>
</div>
</div>
);
})
) : (
<p className="py-8 text-center text-sm text-[#667085]">Сообщений пока нет</p>
)}
<div ref={messagesEndRef} />
</div>
<div className="flex items-center gap-2 border-t border-[#eceef4] p-3">
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Сообщение"
className="rounded-xl"
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void handleSend();
}
}}
/>
<Button className="h-10 w-10 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>
) : null}
</>
);
}