group chat init

This commit is contained in:
Виталий Лавшонок
2025-11-21 22:10:26 +03:00
parent e904297bb9
commit 304c734169
5 changed files with 219 additions and 7 deletions

View File

@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "vite --host",
"build": "tsc && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },

View File

@@ -0,0 +1,190 @@
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import axios from '../../axios';
// =========================================
// Типы
// =========================================
export type Status = 'idle' | 'loading' | 'successful' | 'failed';
export interface ChatMessage {
id: number;
groupId: number;
authorId: number;
authorUsername: string;
content: string;
createdAt: string;
}
interface FetchMessagesParams {
groupId: number;
limit?: number;
afterMessageId?: number | null;
afterCreatedAt?: string | null;
timeoutSeconds?: number;
}
interface SendMessageParams {
groupId: number;
content: string;
}
// =========================================
// State
// =========================================
interface GroupChatState {
messages: Record<number, ChatMessage[]>; // messages[groupId][]
fetchMessages: {
status: Status;
error?: string;
};
sendMessage: {
status: Status;
error?: string;
};
}
const initialState: GroupChatState = {
messages: {},
fetchMessages: {
status: 'idle',
error: undefined,
},
sendMessage: {
status: 'idle',
error: undefined,
},
};
// =========================================
// Thunks
// =========================================
// Получить сообщения группы (обычный запрос или long polling)
export const fetchGroupMessages = createAsyncThunk(
'groupChat/fetchGroupMessages',
async (params: FetchMessagesParams, { rejectWithValue }) => {
try {
const response = await axios.get(`/groups/${params.groupId}/chat`, {
params: {
limit: params.limit,
afterMessageId: params.afterMessageId,
afterCreatedAt: params.afterCreatedAt,
timeoutSeconds: params.timeoutSeconds,
},
});
return {
groupId: params.groupId,
messages: response.data as ChatMessage[],
};
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message ||
'Ошибка при получении сообщений группы',
);
}
},
);
// Отправить новое сообщение
export const sendGroupMessage = createAsyncThunk(
'groupChat/sendGroupMessage',
async ({ groupId, content }: SendMessageParams, { rejectWithValue }) => {
try {
const response = await axios.post(`/groups/${groupId}/chat`, {
content,
});
return response.data as ChatMessage;
} catch (err: any) {
return rejectWithValue(
err.response?.data?.message || 'Ошибка при отправке сообщения',
);
}
},
);
// =========================================
// Slice
// =========================================
const groupChatSlice = createSlice({
name: 'groupChat',
initialState,
reducers: {
clearChat(state, action: PayloadAction<number>) {
delete state.messages[action.payload];
},
},
extraReducers: (builder) => {
// fetchGroupMessages
builder.addCase(fetchGroupMessages.pending, (state) => {
state.fetchMessages.status = 'loading';
});
builder.addCase(
fetchGroupMessages.fulfilled,
(
state,
action: PayloadAction<{
groupId: number;
messages: ChatMessage[];
}>,
) => {
state.fetchMessages.status = 'successful';
const { groupId, messages } = action.payload;
if (!state.messages[groupId]) state.messages[groupId] = [];
// Если пришли последние сообщения — заменяем
// Если long polling — дополняем
if (messages.length > 0) {
const existing = state.messages[groupId];
// Фильтруем дубликаты
const ids = new Set(existing.map((m) => m.id));
const newMessages = messages.filter((m) => !ids.has(m.id));
state.messages[groupId] = [...existing, ...newMessages];
}
},
);
builder.addCase(fetchGroupMessages.rejected, (state, action: any) => {
state.fetchMessages.status = 'failed';
state.fetchMessages.error = action.payload;
});
// sendMessage
builder.addCase(sendGroupMessage.pending, (state) => {
state.sendMessage.status = 'loading';
});
builder.addCase(
sendGroupMessage.fulfilled,
(state, action: PayloadAction<ChatMessage>) => {
state.sendMessage.status = 'successful';
const msg = action.payload;
if (!state.messages[msg.groupId]) {
state.messages[msg.groupId] = [];
}
state.messages[msg.groupId].push(msg);
},
);
builder.addCase(sendGroupMessage.rejected, (state, action: any) => {
state.sendMessage.status = 'failed';
state.sendMessage.error = action.payload;
});
},
});
export const { clearChat } = groupChatSlice.actions;
export const groupChatReducer = groupChatSlice.reducer;

View File

@@ -7,6 +7,7 @@ import { contestsReducer } from './slices/contests';
import { groupsReducer } from './slices/groups'; import { groupsReducer } from './slices/groups';
import { articlesReducer } from './slices/articles'; import { articlesReducer } from './slices/articles';
import { groupFeedReducer } from './slices/groupfeed'; import { groupFeedReducer } from './slices/groupfeed';
import { groupChatReducer } from './slices/groupChat';
// использование // использование
// import { useAppDispatch, useAppSelector } from '../redux/hooks'; // import { useAppDispatch, useAppSelector } from '../redux/hooks';
@@ -27,6 +28,7 @@ export const store = configureStore({
groups: groupsReducer, groups: groupsReducer,
articles: articlesReducer, articles: articlesReducer,
groupfeed: groupFeedReducer, groupfeed: groupFeedReducer,
groupchat: groupChatReducer,
}, },
}); });

View File

@@ -36,7 +36,7 @@ const Group: FC<GroupsBlockProps> = () => {
<Routes> <Routes>
<Route path="home" element={<Posts groupId={groupId} />} /> <Route path="home" element={<Posts groupId={groupId} />} />
<Route path="chat" element={<Chat />} /> <Route path="chat" element={<Chat groupId={groupId} />} />
<Route path="contests" element={<Contests />} /> <Route path="contests" element={<Contests />} />
<Route <Route
path="*" path="*"

View File

@@ -1,17 +1,37 @@
import { useEffect } from 'react'; import { FC, useEffect } from 'react';
import { useAppDispatch } from '../../../../redux/hooks'; import { useAppDispatch } from '../../../../redux/hooks';
import { setMenuActiveGroupPage } from '../../../../redux/slices/store'; import { setMenuActiveGroupPage } from '../../../../redux/slices/store';
import { SearchInput } from '../../../../components/input/SearchInput';
export const Chat = () => { interface GroupChatProps {
groupId: number;
}
export const Chat: FC<GroupChatProps> = ({ groupId }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {
dispatch(setMenuActiveGroupPage('chat')); dispatch(setMenuActiveGroupPage('chat'));
}, []); }, []);
return ( return (
<div className="h-full overflow-y-scroll thin-dark-scrollbar flex items-center justify-center font-bold text-liquid-white text-[50px]"> <div className="h-full relative">
{' '} <div className="grid grid-rows-[40px,1fr,50px] h-full relative min-h-0 gap-[20px]">
Пока пусто :( <div className="relative">
<SearchInput
className="w-[216px]"
onChange={() => {}}
placeholder="Поиск сообщений"
/>
</div>
<div className="min-h-0 overflow-y-scroll thin-dark-scrollbar">
<div className="flex flex-col gap-[20px] min-h-0 h-0">
{/* сообщения */}
</div>
</div>
<div className=" bg-red-300">footer / input bar</div>
</div>
</div> </div>
); );
}; };