fix and update

This commit is contained in:
lendry
2026-06-25 16:01:41 +03:00
parent ce58e6f4c1
commit 3880c68d59
40 changed files with 1715 additions and 215 deletions

View File

@@ -0,0 +1,394 @@
'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}
</>
);
}