/
viktorphp
/
front-socket
Обзор
Документация
Войти
/
viktorphp
/
front-socket
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
src/features/chat/ui/Chat.tsx
521 строка
18 KB
Иванов Виктор Евгеньевич
[master]: исправить переход в запись календаря
19 май 2026, 03:41
19 май 2026, 03:41
7ff9e9b
Код
Авторство
О чём код?
'use client'; import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'; import { alpha, Avatar, Badge, Box, Button, List, ListItem, ListItemButton, ListItemText, Paper, Stack, TextField, Typography, } from '@mui/material'; import { useWebSocketStore } from '@entities/webSocket'; import { socketService } from '@shared/api/socket'; import { IChatItem } from '@shared/types'; import { ChatBubbleSVG } from './ChatBubble'; export const Chat = () => { const { messages, chats, foundUsers, userId } = useWebSocketStore( (state) => state ); const selectedChatRef = useRef<IChatItem | null>(null); const [content, setContent] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const [hoveredChatIndex, setHoveredChatIndex] = useState<number | null>(null); const [isDividerHovered, setIsDividerHovered] = useState(false); /** Ширина левой панели в процентах */ const [leftPanelPct, setLeftPanelPct] = useState(28); const isDraggingRef = useRef(false); const rootRef = useRef<HTMLDivElement | null>(null); const handleDividerMouseDown = useCallback(() => { isDraggingRef.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; }, []); useEffect(() => { const onMouseMove = (e: MouseEvent) => { if (!isDraggingRef.current || !rootRef.current) return; const rect = rootRef.current.getBoundingClientRect(); const pct = ((e.clientX - rect.left) / rect.width) * 100; setLeftPanelPct(Math.min(Math.max(pct, 15), 60)); }; const onMouseUp = () => { if (!isDraggingRef.current) return; isDraggingRef.current = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; }; window.addEventListener('mousemove', onMouseMove); window.addEventListener('mouseup', onMouseUp); return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); }; }, []); /** true - если пользователь долистал до конца все непрочитанные сообщения */ const [wasRead, setWasRead] = useState(false); /** Первое непрочитанное сообщение */ const firstUnreadMessageRef = useRef<HTMLDivElement | null>(null); /** Последнее сообщение в списке */ const lastMessageRef = useRef<HTMLDivElement | null>(null); const handleChatSelect = (chat: IChatItem) => { // Сохраняем выбранный чат в ссылке selectedChatRef.current = chat; // Сбрасываем счетчик непрочитанных сообщений // socketService.sendUpdateIsRead(chat.partner.id); // Запрашиваем историю сообщений для выбранного чата socketService.sendGetConversation(chat.partner.id); }; const handleSubmit = (e: FormEvent) => { e.preventDefault(); if (!selectedChatRef.current || !content) return; socketService.sendMessage(selectedChatRef.current.partner.id, content); firstUnreadMessageRef.current = lastMessageRef.current; socketService.sendUpdateIsRead(selectedChatRef.current?.partner.id); setContent(''); }; const handleSearchSubmit = (e: FormEvent) => { e.preventDefault(); if (!searchQuery) return; // Отправляем событие поиска пользователя socketService.searchUser(searchQuery); }; const containerRef = useRef<HTMLUListElement | null>(null); const maxWidthPercentage = 80; // Используем ширину окна для начального значения const initialMaxWidth = typeof window !== 'undefined' ? Math.floor((window.innerWidth * (maxWidthPercentage - 30)) / 100) : 350; const [maxWidth, setMaxWidth] = useState(initialMaxWidth); useEffect(() => { const updateMaxWidth = () => { if (containerRef.current) { const parentWidth = containerRef.current.offsetWidth; const calculatedMaxWidth = Math.floor( (parentWidth * maxWidthPercentage) / 100 ); setMaxWidth(calculatedMaxWidth); } }; updateMaxWidth(); window.addEventListener('resize', updateMaxWidth); return () => { window.removeEventListener('resize', updateMaxWidth); }; }, [maxWidthPercentage]); useEffect(() => { // сразу прокручиваем вниз перед отрисовкой if (containerRef.current) { setTimeout(() => { const nodeToScroll = wasRead ? lastMessageRef.current : (firstUnreadMessageRef.current ?? lastMessageRef.current); nodeToScroll?.scrollIntoView({ behavior: 'auto', block: 'end', }); }, 60); } }, [selectedChatRef.current, messages, content]); useEffect(() => { if (!containerRef.current || !firstUnreadMessageRef.current) return; // Обработчик пересечения последнего сообщения const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { // Когда последний элемент полностью (threshold: 1) виден в контейнере if (entry.isIntersecting && selectedChatRef.current?.partner?.id) { socketService.sendUpdateIsRead(selectedChatRef.current?.partner.id); setWasRead(true); // Отписываемся т.к. нужено обработать пересечение только один раз // observer.unobserve(entry.target); } }); }, { root: containerRef.current, rootMargin: '0px', threshold: 1, } ); // Стартуем наблюдение за «якорным» элементом (последним сообщением) observer.observe(firstUnreadMessageRef.current); return () => { observer.disconnect(); }; }, [messages]); /** Индекс последнего не прочитанного сообщения */ const firstUnreadMessageIndex = messages?.findIndex((el) => !el.isRead); return ( <Stack ref={rootRef} direction={'row'} sx={{ flex: 1, minHeight: 0, width: '100%', padding: '16px', boxSizing: 'border-box', bgcolor: 'background.default', overflow: 'hidden', }} > <Stack direction={'column'} justifyContent={'space-between'} sx={{ width: `${leftPanelPct}%`, flexShrink: 0, height: '100%', bgcolor: '#e8f2e4', borderRadius: 3, p: 1.5, boxSizing: 'border-box', }} > <Stack direction={'column'} sx={{ flex: 1, minHeight: 0 }}> <Typography variant="h6" gutterBottom> Список чатов: </Typography> <List disablePadding sx={{ height: '100%', overflow: 'auto' }}> {chats?.map((chat, index) => { const pid = chat.partner.id; const unreadCount = chat.unreadCount || 0; return ( <Paper key={`paper-${pid}-${index}`} elevation={2} sx={{ m: '12px 3px', borderRadius: 3, border: '1.5px solid #c8e0c2', overflow: 'hidden', }} onMouseEnter={() => setHoveredChatIndex(index)} onMouseLeave={() => setHoveredChatIndex(null)} style={{ background: hoveredChatIndex === index ? 'linear-gradient(135deg, #b8d4b2 0%, #a8c8a0 100%)' : 'linear-gradient(135deg, #c8dfc2 0%, #b8d4b2 100%)', transition: 'background 0.2s ease', }} > <ListItemButton key={`${pid}-${index}`} onClick={() => handleChatSelect(chat)} sx={{ minWidth: '100%', overflow: 'visible', pt: '12px', }} > <Badge key={`badge-${pid}-${index}`} color={'primary'} badgeContent={unreadCount} invisible={unreadCount === 0} overlap={'rectangular'} sx={{ width: '100%', overflow: 'visible' }} > <Stack direction={'column'} spacing={1} sx={{ width: '100%' }} > <Stack direction={'row'} spacing={2} alignItems={'center'} sx={{ width: '100%' }} > <Badge variant={'dot'} color={chat.isOnline ? 'success' : 'error'} > <Avatar src={chat?.partner?.avatar ?? ''} sx={{ border: '2px solid', borderColor: chat.isOnline ? 'success.main' : 'error.main', boxShadow: chat.isOnline ? '0 0 6px 2px rgba(108,196,68,0.5)' : '0 0 6px 2px rgba(254,0,0,0.35)', width: 52, height: 52, }} /> </Badge> <Typography component={'span'} variant={'body1'} sx={{ fontWeight: 'bold', fontSize: '1.05rem' }} > {`${chat.partner.username}`} </Typography> </Stack> <Box> <Typography component={'span'} variant={'body2'} sx={{ fontWeight: 'bold', fontSize: '0.95rem' }} > Последнее сообщение: </Typography> <Typography variant={'body2'} sx={{ whiteSpace: 'normal', wordBreak: 'break-all', overflowWrap: 'break-word', fontSize: '0.95rem', }} >{`${chat.lastMessage.content}`}</Typography> </Box> </Stack> </Badge> </ListItemButton> </Paper> ); })} </List> </Stack> <Paper elevation={2} sx={{ p: 2, mt: 2, minHeight: 0, maxHeight: '45%' }} > <Stack direction={'column'} sx={{ height: '100%' }}> <Typography variant="h6" gutterBottom> Найти пользователя для переписки: </Typography> <Box component="form" onSubmit={handleSearchSubmit} sx={{ display: 'flex', gap: 1, mb: 2 }} > <TextField type="text" placeholder="Введите имя или id пользователя" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} size={'small'} fullWidth /> <Button type="submit" variant="contained" color="primary" size={'small'} > Найти </Button> </Box> {(foundUsers ?? []).length > 0 && ( <List sx={{ flex: 1, minHeight: 0, overflowY: 'auto' }}> {foundUsers?.map((user, index) => ( <ListItemButton key={`${user.userId}-${index}`} onClick={() => { const newChat: IChatItem = { partner: { id: user.userId, username: user.username, avatar: null, }, lastMessage: { id: '', content: '', createdAt: new Date(), }, unreadCount: 0, }; selectedChatRef.current = newChat; socketService.sendGetConversation(user.userId); }} sx={{ px: 1, py: 0.5 }} > <ListItemText primary={ <Typography fontSize={'12px'}> {`${user.username} (${user.userId})`} </Typography> } /> </ListItemButton> ))} </List> )} </Stack> </Paper> </Stack> {/* Перетаскиваемый разделитель */} <Box onMouseDown={handleDividerMouseDown} onMouseEnter={() => setIsDividerHovered(true)} onMouseLeave={() => setIsDividerHovered(false)} sx={{ width: '8px', flexShrink: 0, height: '100%', mx: '16px', cursor: 'col-resize', display: 'flex', alignItems: 'center', justifyContent: 'center', }} > <Box style={{ width: '2px', height: '100%', backgroundColor: isDividerHovered ? '#1976d2' : '#9e9e9e', opacity: isDividerHovered ? 1 : 0.7, transition: 'background-color 0.15s ease, opacity 0.15s ease', }} /> </Box> <Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'space-between', flex: 1, minWidth: 0, height: '100%', boxSizing: 'border-box', pl: '10px', }} > {selectedChatRef?.current ? ( <> <Stack direction={'column'} spacing={2} sx={{ height: 'calc(100% - 4rem)', width: '100%', }} > <Typography variant="h6" gutterBottom> История сообщений с{' '} {selectedChatRef?.current?.partner?.username}: </Typography> <List ref={containerRef} sx={{ display: 'flex', height: '95%', overflowY: 'auto', flexDirection: 'column', backgroundColor: (theme) => alpha(theme.palette.secondary.light, 0.4), boxShadow: (theme) => `0 0 5px 5px ${alpha(theme.palette.secondary.light, 0.4)}`, borderRadius: '5px', }} > {messages?.map((message, index) => { return ( <ListItem ref={ index === firstUnreadMessageIndex ? firstUnreadMessageRef : undefined } component={'div'} key={index} sx={{ position: 'relative', justifyContent: message.sender?.id === userId ? 'flex-end' : 'flex-start', }} > <ChatBubbleSVG message={message} isOwn={message.sender?.id === userId} maxWidth={maxWidth} /> </ListItem> ); })} <div ref={lastMessageRef} /> </List> </Stack> <Box component="form" onSubmit={handleSubmit} sx={{ mt: 2, display: 'flex', gap: 1, alignItems: 'center' }} > <TextField label="Сообщение" type="text" value={content} onChange={(e) => setContent(e.target.value)} size={'small'} fullWidth /> <Button type="submit" variant="contained" color="primary" size={'small'} > Отправить сообщение </Button> </Box> </> ) : ( <Typography variant="body1"> Выберите чат или найдите пользователя для начала переписки </Typography> )} </Box> </Stack> ); };