add group chat

This commit is contained in:
Виталий Лавшонок
2025-11-23 10:30:31 +03:00
parent abb7301c16
commit 390f1f52c8
28 changed files with 414 additions and 217 deletions

View File

@@ -1,8 +1,14 @@
import { FC, useEffect, useRef } from 'react';
import { FC, useEffect, useRef, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../../../../redux/hooks';
import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
import { fetchGroupMessages } from '../../../../redux/slices/groupChat';
import {
fetchGroupMessages,
sendGroupMessage,
setGroupChatStatus,
} from '../../../../redux/slices/groupChat';
import { SearchInput } from '../../../../components/input/SearchInput';
import { MessageItem } from './MessageItem';
import { Send } from '../../../../assets/icons/input';
interface GroupChatProps {
groupId: number;
@@ -13,22 +19,27 @@ const CHUNK_SIZE = 10;
export const Chat: FC<GroupChatProps> = ({ groupId }) => {
const dispatch = useAppDispatch();
const messages = useAppSelector((s) => s.groupchat.messages[groupId] || []);
const hasMore = useAppSelector((s) => s.groupchat.hasMore[groupId]);
const isInitialLoaded = useAppSelector(
(s) => s.groupchat.isInitialLoaded[groupId],
const messagesState = useAppSelector(
(state) => state.groupchat.fetchMessages.status,
);
const lastMessageId = useAppSelector(
(state) => state.groupchat.lastMessage[groupId] || 0,
);
const user = useAppSelector((state) => state.auth);
const [text, setText] = useState<string>('');
const [firstMessagesFetch, setFirctMessagesFetch] = useState<boolean>(true);
const scrollRef = useRef<HTMLDivElement>(null);
// добавлено: ref для хранения предыдущей высоты
const prevHeightRef = useRef(0);
// активируем таб
useEffect(() => {
dispatch(setMenuActiveGroupPage('chat'));
}, []);
useEffect(() => {
console.log(messages);
}, [messages]);
// первичная загрузка
useEffect(() => {
dispatch(
@@ -39,23 +50,112 @@ export const Chat: FC<GroupChatProps> = ({ groupId }) => {
);
}, [groupId]);
// автоскролл вниз после начальной загрузки
// автоскролл вниз после начальной загрузки (но не при догрузке)
useEffect(() => {
if (!isInitialLoaded || !scrollRef.current) return;
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [isInitialLoaded, messages.length]);
const div = scrollRef.current;
if (!div) return;
// если prevHeightRef == 0 — значит это не догрузка, а обычная загрузка
if (prevHeightRef.current === 0) {
div.scrollTop = div.scrollHeight;
}
}, [messages.length]);
// добавлено: компенсирование скролла при догрузке
useEffect(() => {
const div = scrollRef.current;
if (!div) return;
if (prevHeightRef.current > 0) {
const diff = div.scrollHeight - prevHeightRef.current;
div.scrollTop = diff; // компенсируем смещение
prevHeightRef.current = 0; // сбрасываем
}
}, [messages]);
useEffect(() => {
if (messagesState == 'successful') {
dispatch(
setGroupChatStatus({ key: 'fetchMessages', status: 'idle' }),
);
}
if (messagesState == 'failed') {
dispatch(
setGroupChatStatus({ key: 'fetchMessages', status: 'idle' }),
);
}
}, [messagesState]);
const lastMessageIdRef = useRef<number | null>(null);
useEffect(() => {
lastMessageIdRef.current = lastMessageId;
if (firstMessagesFetch) {
setFirctMessagesFetch(false);
dispatch(
fetchGroupMessages({
groupId,
afterMessageId: lastMessageIdRef.current,
timeoutSeconds: 10,
}),
);
}
}, [messages]);
useEffect(() => {
const interval = setInterval(() => {
if (lastMessageIdRef.current === null) return;
dispatch(
fetchGroupMessages({
groupId,
afterMessageId: lastMessageIdRef.current,
timeoutSeconds: 10,
}),
);
}, 10000);
return () => clearInterval(interval);
}, [groupId]);
const handleSend = () => {
if (!text.trim()) return;
dispatch(
sendGroupMessage({
groupId,
content: text.trim(),
}),
).then(() => {
setText('');
setTimeout(() => {
const div = scrollRef.current;
if (div) div.scrollTop = div.scrollHeight;
}, 0);
});
};
const handleEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSend();
}
};
// догрузка старых сообщений при скролле вверх
const handleScroll = () => {
const div = scrollRef.current;
if (!div || !hasMore) return;
if (!div) return;
// если скролл в верхней точке
if (div.scrollTop === 0) {
prevHeightRef.current = div.scrollHeight; // запоминаем высоту до загрузки
if (div.scrollTop < 100) {
const first = messages[0];
if (!first) return;
if (!first || first.id == 1) return;
const beforeId = first.id - CHUNK_SIZE;
console.log(beforeId);
dispatch(
fetchGroupMessages({
@@ -69,7 +169,7 @@ export const Chat: FC<GroupChatProps> = ({ groupId }) => {
return (
<div className="h-full relative">
<div className="grid grid-rows-[40px,1fr,50px] h-full relative min-h-0 gap-[20px]">
<div className="grid grid-rows-[40px,1fr,40px] h-full relative min-h-0 gap-[20px]">
<div className="relative">
<SearchInput
className="w-[216px]"
@@ -83,25 +183,38 @@ export const Chat: FC<GroupChatProps> = ({ groupId }) => {
ref={scrollRef}
onScroll={handleScroll}
>
<div className="flex flex-col gap-[20px] min-h-0 h-0">
{messages.map((msg) => (
<div
key={msg.id}
className="bg-gray-800 text-white p-3 rounded-lg"
>
<div className="text-sm opacity-60">
{msg.authorUsername} {msg.id}
</div>
<div>{msg.content}</div>
<div className="text-[10px] opacity-40 mt-1">
{new Date(msg.createdAt).toLocaleString()}
</div>
</div>
<div className="flex flex-col gap-[20px] min-h-0 h-0 px-[16px]">
{messages.map((msg, i) => (
<MessageItem
key={i}
message={msg.content}
createdAt={msg.createdAt}
id={msg.id}
groupId={msg.groupId}
authorId={msg.authorId}
authorUsername={msg.authorUsername}
myMessage={msg.authorId == Number(user.id)}
/>
))}
</div>
</div>
<div className="bg-red-300">footer / input bar</div>
<label className="bg-liquid-lighter rounded-[10px] cursor-text flex items-center px-[16px]">
<input
className="w-[calc(100%-50px)] outline-none bg-transparent placeholder:text-[16px] placeholder:text-liquid-light placeholder:font-medium"
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleEnter}
placeholder="Введите сообщение"
/>
<img
src={Send}
className=" absolute cursor-pointer right-[16px] active:scale-90 transition-all duration-300"
onClick={() => {
handleSend();
}}
/>
</label>
</div>
</div>
);