271 lines
9.6 KiB
TypeScript
271 lines
9.6 KiB
TypeScript
'use client';
|
||
|
||
import React from 'react';
|
||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||
import { Box, Typography } from '@mui/material';
|
||
import { getConversations, getChatById } from '@/api/chat';
|
||
import type { Chat } from '@/api/chat';
|
||
import { ChatList } from '@/components/chat/ChatList';
|
||
import { ChatWindow } from '@/components/chat/ChatWindow';
|
||
import { usePresenceWebSocket } from '@/hooks/usePresenceWebSocket';
|
||
import { useAuth } from '@/contexts/AuthContext';
|
||
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
|
||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||
|
||
export default function ChatPage() {
|
||
const { user } = useAuth();
|
||
const router = useRouter();
|
||
const pathname = usePathname();
|
||
const searchParams = useSearchParams();
|
||
const uuidFromUrl = searchParams.get('uuid');
|
||
const isMobile = useIsMobile();
|
||
|
||
const [loading, setLoading] = React.useState(true);
|
||
const [chats, setChats] = React.useState<Chat[]>([]);
|
||
const [selected, setSelected] = React.useState<Chat | null>(null);
|
||
const [error, setError] = React.useState<string | null>(null);
|
||
const [hasMore, setHasMore] = React.useState(false);
|
||
const [page, setPage] = React.useState(1);
|
||
const [loadingMore, setLoadingMore] = React.useState(false);
|
||
usePresenceWebSocket({ enabled: true });
|
||
const refreshNavBadges = useNavBadgesRefresh();
|
||
|
||
// На странице чата не должно быть общего скролла (скроллим только панели внутри)
|
||
React.useEffect(() => {
|
||
const prevHtml = document.documentElement.style.overflow;
|
||
const prevBody = document.body.style.overflow;
|
||
document.documentElement.style.overflow = 'hidden';
|
||
document.body.style.overflow = 'hidden';
|
||
return () => {
|
||
document.documentElement.style.overflow = prevHtml;
|
||
document.body.style.overflow = prevBody;
|
||
};
|
||
}, []);
|
||
|
||
const normalizeChat = React.useCallback((c: any) => {
|
||
const otherName =
|
||
c?.other_participant?.full_name ||
|
||
[c?.other_participant?.first_name, c?.other_participant?.last_name].filter(Boolean).join(' ') ||
|
||
c?.participant_name ||
|
||
'Чат';
|
||
const avatarUrl = c?.other_participant?.avatar_url || c?.other_participant?.avatar || null;
|
||
const otherId = c?.other_participant?.id ?? null;
|
||
const otherOnline = !!c?.other_participant?.is_online;
|
||
const otherLast = c?.other_participant?.last_activity ?? null;
|
||
const lastText = c?.last_message?.content || c?.last_message?.text || c?.last_message || '';
|
||
const unread = c?.my_participant?.unread_count ?? c?.unread_count ?? 0;
|
||
return {
|
||
id: c.id,
|
||
uuid: c.uuid,
|
||
participant_name: otherName,
|
||
avatar_url: avatarUrl,
|
||
other_user_id: otherId,
|
||
other_is_online: otherOnline,
|
||
other_last_activity: otherLast,
|
||
last_message: lastText,
|
||
unread_count: unread,
|
||
created_at: c.created_at,
|
||
};
|
||
}, []);
|
||
|
||
React.useEffect(() => {
|
||
(async () => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
const resp = await getConversations({ page: 1, page_size: 30 });
|
||
const normalized = (resp.results || []).map((c: any) => normalizeChat(c));
|
||
setChats(normalized as any);
|
||
setHasMore(!!(resp as any).next);
|
||
setPage(1);
|
||
} catch (e: any) {
|
||
console.error('[ChatPage] Ошибка загрузки чатов:', e);
|
||
const msg =
|
||
e?.response?.data?.detail ||
|
||
e?.response?.data?.error ||
|
||
e?.message ||
|
||
'Не удалось загрузить чаты';
|
||
setError(String(msg));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
})();
|
||
}, [normalizeChat]);
|
||
|
||
const restoredForUuidRef = React.useRef<string | null>(null);
|
||
|
||
// Восстановить выбранный чат из URL после загрузки списка (или по uuid)
|
||
React.useEffect(() => {
|
||
if (loading || error || !uuidFromUrl) return;
|
||
if (restoredForUuidRef.current === uuidFromUrl) return;
|
||
const found = chats.find((c) => (c as any).uuid === uuidFromUrl);
|
||
if (found) {
|
||
setSelected(found as Chat);
|
||
restoredForUuidRef.current = uuidFromUrl;
|
||
return;
|
||
}
|
||
(async () => {
|
||
try {
|
||
const c = await getChatById(uuidFromUrl);
|
||
const normalized = normalizeChat(c) as any;
|
||
setSelected(normalized as Chat);
|
||
restoredForUuidRef.current = uuidFromUrl;
|
||
} catch (e: any) {
|
||
console.warn('[ChatPage] Чат по uuid из URL не найден:', uuidFromUrl, e);
|
||
restoredForUuidRef.current = null;
|
||
router.replace(pathname ?? '/chat');
|
||
}
|
||
})();
|
||
}, [loading, error, uuidFromUrl, chats, normalizeChat, router, pathname]);
|
||
|
||
React.useEffect(() => {
|
||
if (!uuidFromUrl) restoredForUuidRef.current = null;
|
||
}, [uuidFromUrl]);
|
||
|
||
const handleSelectChat = React.useCallback(
|
||
(c: Chat) => {
|
||
setSelected(c);
|
||
const u = (c as any).uuid;
|
||
if (u) {
|
||
const base = pathname ?? '/chat';
|
||
router.replace(`${base}?uuid=${encodeURIComponent(u)}`);
|
||
}
|
||
},
|
||
[router, pathname]
|
||
);
|
||
|
||
const loadMore = React.useCallback(async () => {
|
||
if (loadingMore || !hasMore) return;
|
||
try {
|
||
setLoadingMore(true);
|
||
const next = page + 1;
|
||
const resp = await getConversations({ page: next, page_size: 30 });
|
||
const normalized = (resp.results || []).map((c: any) => normalizeChat(c));
|
||
setChats((prev) => [...prev, ...(normalized as any)]);
|
||
setHasMore(!!(resp as any).next);
|
||
setPage(next);
|
||
} catch (e: any) {
|
||
console.error('[ChatPage] Ошибка загрузки чатов:', e);
|
||
} finally {
|
||
setLoadingMore(false);
|
||
}
|
||
}, [page, hasMore, loadingMore, normalizeChat]);
|
||
|
||
const refreshChatListUnread = React.useCallback(async () => {
|
||
try {
|
||
const resp = await getConversations({ page: 1, page_size: 30 });
|
||
const fresh = (resp.results || []).map((c: any) => normalizeChat(c)) as Chat[];
|
||
const freshByUuid = new Map(fresh.map((c: any) => [(c as any).uuid, c]));
|
||
setChats((prev) =>
|
||
prev.map((c: any) => {
|
||
const updated = freshByUuid.get(c.uuid);
|
||
return updated ? (updated as Chat) : c;
|
||
})
|
||
);
|
||
await refreshNavBadges?.();
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}, [normalizeChat, refreshNavBadges]);
|
||
|
||
const handleBackToList = React.useCallback(() => {
|
||
setSelected(null);
|
||
router.replace(pathname ?? '/chat');
|
||
}, [router, pathname]);
|
||
|
||
// Mobile: show only list or only chat
|
||
const mobileShowChat = isMobile && selected != null;
|
||
|
||
// Hide bottom navigation when a chat is open on mobile
|
||
React.useEffect(() => {
|
||
if (mobileShowChat) {
|
||
document.documentElement.classList.add('mobile-chat-open');
|
||
} else {
|
||
document.documentElement.classList.remove('mobile-chat-open');
|
||
}
|
||
return () => {
|
||
document.documentElement.classList.remove('mobile-chat-open');
|
||
};
|
||
}, [mobileShowChat]);
|
||
|
||
return (
|
||
<div className="ios26-dashboard ios26-chat-page" style={{ padding: isMobile ? '8px' : '16px' }}>
|
||
<Box
|
||
className="ios26-chat-layout"
|
||
sx={{
|
||
display: isMobile ? 'flex' : 'grid',
|
||
gridTemplateColumns: isMobile ? undefined : '320px 1fr',
|
||
flexDirection: isMobile ? 'column' : undefined,
|
||
gap: 'var(--ios26-spacing)',
|
||
alignItems: 'stretch',
|
||
height: isMobile ? '100%' : 'calc(90vh - 32px)',
|
||
maxHeight: isMobile ? undefined : 'calc(90vh - 32px)',
|
||
overflow: 'hidden',
|
||
}}
|
||
>
|
||
{/* Chat list: hidden on mobile when a chat is selected */}
|
||
{!mobileShowChat && (
|
||
<>
|
||
{loading ? (
|
||
<Box
|
||
className="ios-glass-panel"
|
||
sx={{
|
||
borderRadius: '20px',
|
||
p: 2,
|
||
display: 'flex',
|
||
flex: isMobile ? 1 : undefined,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
background: 'var(--ios26-glass)',
|
||
border: '1px solid var(--ios26-glass-border)',
|
||
backdropFilter: 'var(--ios26-blur)',
|
||
}}
|
||
>
|
||
<Typography>Загрузка…</Typography>
|
||
</Box>
|
||
) : error ? (
|
||
<Box
|
||
className="ios-glass-panel"
|
||
sx={{
|
||
borderRadius: '20px',
|
||
p: 2,
|
||
display: 'flex',
|
||
flex: isMobile ? 1 : undefined,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
color: 'var(--md-sys-color-on-surface-variant)',
|
||
background: 'var(--ios26-glass)',
|
||
border: '1px solid var(--ios26-glass-border)',
|
||
backdropFilter: 'var(--ios26-blur)',
|
||
}}
|
||
>
|
||
<Typography>{error}</Typography>
|
||
</Box>
|
||
) : (
|
||
<ChatList
|
||
chats={chats}
|
||
selectedChatUuid={selected?.uuid ?? (selected as any)?.uuid ?? null}
|
||
onSelect={handleSelectChat}
|
||
hasMore={hasMore}
|
||
loadingMore={loadingMore}
|
||
onLoadMore={loadMore}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Chat window: on mobile only visible when a chat is selected */}
|
||
{(!isMobile || mobileShowChat) && (
|
||
<ChatWindow
|
||
chat={selected}
|
||
currentUserId={user?.id ?? null}
|
||
onBack={isMobile ? handleBackToList : undefined}
|
||
onMessagesMarkedAsRead={refreshChatListUnread}
|
||
/>
|
||
)}
|
||
</Box>
|
||
</div>
|
||
);
|
||
}
|