-
+
+
+
+ } />
+ } />
+
-
+ >
);
};
diff --git a/frontend/src/pages/subpages/Chats.tsx b/frontend/src/pages/subpages/Chats.tsx
new file mode 100644
index 0000000..bd7c3b1
--- /dev/null
+++ b/frontend/src/pages/subpages/Chats.tsx
@@ -0,0 +1,16 @@
+import ChatSidebar from "@/components/chats/ChatSidebar";
+import ChatWindowWrapper from "@/components/chats/ChatWindow_wrapper";
+import { Card, CardContent } from "@/components/ui/card";
+
+export const ChatsPage = () => {
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/subpages/Main.tsx b/frontend/src/pages/subpages/Main.tsx
new file mode 100644
index 0000000..a89f150
--- /dev/null
+++ b/frontend/src/pages/subpages/Main.tsx
@@ -0,0 +1,15 @@
+import { CreatePostWrapper } from "../../components/posts/createpost_wrapper";
+import { PostList } from "../../components/posts/postlist";
+import { UserPanel } from "../../components/users/usersPanel";
+
+export function MainPage() {
+ return (
+
+ );
+}
diff --git a/frontend/src/statemanagement/chats/chatSlice.ts b/frontend/src/statemanagement/chats/chatSlice.ts
new file mode 100644
index 0000000..ee0ce02
--- /dev/null
+++ b/frontend/src/statemanagement/chats/chatSlice.ts
@@ -0,0 +1,183 @@
+import { ChatMessage } from "@/types/chatmessage.type";
+import { ChatRoom, ChatRoomWithNotifications } from "@/types/chatroom.type";
+import { createUniqueMessages } from "@/utils/unreadMessages";
+import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
+export interface ChatState {
+ chats: ChatRoomWithNotifications[];
+ chatsAreLoading: boolean;
+ chatIsLoading: boolean;
+ messageSending: boolean;
+ activeChatId?: string;
+}
+
+const initialState: ChatState = {
+ chats: [],
+ chatsAreLoading: false,
+ chatIsLoading: false,
+ messageSending: false,
+};
+
+const setMessagesForChat = (
+ chat: ChatRoomWithNotifications,
+ messagesToAdd?: ChatMessage[]
+) => {
+ const { messages, noNewMessages } = createUniqueMessages(
+ chat.messages,
+ messagesToAdd
+ );
+ chat.messages = messages;
+ chat.unreadMessages = noNewMessages;
+};
+
+export const getAllChats = createAsyncThunk("chats/getAll", async (_, s) => {
+ let token = window.sessionStorage.getItem("token");
+ const response = await fetch(
+ "https://" + window.envUrl + "/api/v1/chats/query/chats/",
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ }
+ );
+ if (response.status > 299) {
+ throw new Error("Request failed with " + response.status);
+ }
+ const data = (await response.json()) as ChatRoomWithNotifications[];
+ data.forEach((chat) => {
+ chat.unreadMessages = 0;
+ });
+ if (data.length > 0) {
+ s.dispatch(getChatById({ chatId: data[0].id }));
+ }
+
+ return data;
+});
+
+export const getChatById = createAsyncThunk(
+ "chats/getById",
+ async ({ chatId }: { chatId: string }, t) => {
+ console.log(chatId);
+ let token = window.sessionStorage.getItem("token");
+
+ const activeChatId = (t.getState() as { chats: ChatState }).chats
+ .activeChatId;
+ if (activeChatId == chatId) {
+ return { messages: [], chatId };
+ }
+ t.dispatch(setActiveChatId(chatId));
+
+ const response = await fetch(
+ "https://" + window.envUrl + `/api/v1/chats/query/chats/${chatId}`,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ }
+ );
+ if (response.status > 299) {
+ throw new Error("Request failed with " + response.status);
+ }
+ const data = await response.json();
+ console.log(data);
+
+ return { messages: data.messages, chatId };
+ }
+);
+
+export const addMessage = createAsyncThunk(
+ "chats/add",
+ async (payload: { text?: string; imageBase64?: string }, t) => {
+ let token = window.sessionStorage.getItem("token");
+ const activeChatId = (t.getState() as { chats: ChatState }).chats
+ .activeChatId;
+ const chat = {
+ text: payload.text,
+ };
+ const response = await fetch(
+ "https://" +
+ window.envUrl +
+ `/api/v1/chats/command/chats/${activeChatId}/message`,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify(chat),
+ method: "POST",
+ }
+ );
+ if (response.status > 299) {
+ throw new Error("Request failed with " + response.status);
+ }
+ return chat;
+ }
+);
+
+export const chatSlice = createSlice({
+ name: "chats",
+ initialState,
+ reducers: {
+ setActiveChatId: (state, action: { payload: string }) => {
+ state.activeChatId = action.payload;
+ },
+ setReadMessages: (state) => {
+ const chat = state.chats.find((x) => x.id == state.activeChatId);
+ if (chat != null) {
+ chat.unreadMessages = 0;
+ }
+ },
+ updateChatRoomMessagesSync: (state, action: { payload: ChatRoom }) => {
+ const chatRoom = state.chats.find((x) => x.id == action.payload.id);
+ if (chatRoom != null) {
+ setMessagesForChat(chatRoom, action.payload.messages);
+ } else {
+ state.chats.push({ ...action.payload, unreadMessages: 1 });
+ }
+ },
+ },
+ extraReducers: (builder) => {
+ builder.addCase(getAllChats.pending, (state) => {
+ state.chatsAreLoading = true;
+ });
+ builder.addCase(getAllChats.rejected, (state) => {
+ state.chatsAreLoading = false;
+ });
+ builder.addCase(getAllChats.fulfilled, (state, action) => {
+ state.chatsAreLoading = false;
+ state.chats = action.payload;
+ });
+ builder.addCase(getChatById.pending, (state) => {
+ state.chatIsLoading = true;
+ });
+ builder.addCase(getChatById.rejected, (state) => {
+ state.chatIsLoading = false;
+ });
+ builder.addCase(
+ getChatById.fulfilled,
+ (
+ state,
+ action: { payload: { chatId: string; messages: ChatMessage[] } }
+ ) => {
+ state.chatIsLoading = false;
+ const chat = state.chats.find((x) => x.id == action.payload.chatId);
+ if (chat != null) {
+ setMessagesForChat(chat, action.payload.messages);
+ }
+ }
+ );
+ builder.addCase(addMessage.pending, (state) => {
+ state.messageSending = true;
+ });
+ builder.addCase(addMessage.rejected, (state, err) => {
+ state.messageSending = false;
+ console.log(err);
+ });
+ builder.addCase(addMessage.fulfilled, (state) => {
+ state.messageSending = false;
+ });
+ },
+});
+
+export const { updateChatRoomMessagesSync, setActiveChatId, setReadMessages } =
+ chatSlice.actions;
+
+export default chatSlice.reducer;
diff --git a/frontend/src/statemanagement/store.ts b/frontend/src/statemanagement/store.ts
index 914ae41..59796d8 100644
--- a/frontend/src/statemanagement/store.ts
+++ b/frontend/src/statemanagement/store.ts
@@ -1,6 +1,7 @@
import { configureStore } from "@reduxjs/toolkit";
import { useDispatch, useSelector } from "react-redux";
import authenticationReducer from "./authentication/authenticationSlice";
+import chatReducer from "./chats/chatSlice";
import postReducer from "./posting/postSlice";
import usersReducer from "./users/usersSlice";
@@ -9,6 +10,7 @@ export const store = configureStore({
authentication: authenticationReducer,
users: usersReducer,
posts: postReducer,
+ chats: chatReducer,
},
});
diff --git a/frontend/src/types/chatmessage.type.ts b/frontend/src/types/chatmessage.type.ts
new file mode 100644
index 0000000..b1fc85b
--- /dev/null
+++ b/frontend/src/types/chatmessage.type.ts
@@ -0,0 +1,7 @@
+export interface ChatMessage {
+ id: string;
+ text: string;
+ image_base64: string;
+ user_id: string;
+ creation_date: string;
+}
diff --git a/frontend/src/types/chatroom.type.ts b/frontend/src/types/chatroom.type.ts
new file mode 100644
index 0000000..4a7e721
--- /dev/null
+++ b/frontend/src/types/chatroom.type.ts
@@ -0,0 +1,13 @@
+import { ChatMessage } from "./chatmessage.type";
+
+export interface ChatRoom {
+ id: string;
+ name: string;
+ user_ids: string[];
+ messages?: ChatMessage[];
+ isLoading?: boolean;
+}
+
+export interface ChatRoomWithNotifications extends ChatRoom {
+ unreadMessages?: number;
+}
diff --git a/frontend/src/utils/unique_array.ts b/frontend/src/utils/unique_array.ts
new file mode 100644
index 0000000..a1eea2c
--- /dev/null
+++ b/frontend/src/utils/unique_array.ts
@@ -0,0 +1,7 @@
+export const unique =
(a: Array, idAccessor: (item: T) => string) => {
+ var seen = {} as { [key: string]: boolean };
+ return a.filter(function (item) {
+ const id = idAccessor(item);
+ return seen.hasOwnProperty(id) ? false : (seen[id] = true);
+ });
+};
diff --git a/frontend/src/utils/unreadMessages.ts b/frontend/src/utils/unreadMessages.ts
new file mode 100644
index 0000000..a01fead
--- /dev/null
+++ b/frontend/src/utils/unreadMessages.ts
@@ -0,0 +1,14 @@
+import { ChatMessage } from "@/types/chatmessage.type";
+import { unique } from "./unique_array";
+
+export const createUniqueMessages = (
+ oldMessages?: ChatMessage[],
+ newMessages?: ChatMessage[]
+) => {
+ const messages = unique(
+ [...(oldMessages ?? []), ...(newMessages ?? [])],
+ (x) => x.id
+ );
+ const noNewMessages = messages.length - (oldMessages?.length ?? 0);
+ return { messages, noNewMessages };
+};
diff --git a/frontend/src/websocket/chatWebsocket.tsx b/frontend/src/websocket/chatWebsocket.tsx
new file mode 100644
index 0000000..49a3358
--- /dev/null
+++ b/frontend/src/websocket/chatWebsocket.tsx
@@ -0,0 +1,17 @@
+import { updateChatRoomMessagesSync } from "@/statemanagement/chats/chatSlice";
+import { useAppDispatch } from "@/statemanagement/store";
+import { ChatRoom } from "@/types/chatroom.type";
+import { useCallback } from "react";
+import { useWebsocket } from "./websocketProvider";
+
+export const useChatWebsocket = () => {
+ const dispatch = useAppDispatch();
+ const onChat = useCallback(
+ (data: unknown) => {
+ dispatch(updateChatRoomMessagesSync(data as ChatRoom));
+ },
+ [dispatch]
+ );
+ const url = `ws://${window.envUrl}/api/v1/chats/query/ws`;
+ useWebsocket(url, [{ eventName: "default", onEvent: onChat }]);
+};
diff --git a/frontend/src/websocket/postWebsocket.tsx b/frontend/src/websocket/postWebsocket.tsx
new file mode 100644
index 0000000..24329d6
--- /dev/null
+++ b/frontend/src/websocket/postWebsocket.tsx
@@ -0,0 +1,17 @@
+import { addPostSync } from "@/statemanagement/posting/postSlice";
+import { useAppDispatch } from "@/statemanagement/store";
+import { useCallback } from "react";
+import { useWebsocket } from "./websocketProvider";
+
+export const usePostWebsocket = () => {
+ const dispatch = useAppDispatch();
+ const onPost = useCallback(
+ (data: any) => {
+ console.log(data);
+ dispatch(addPostSync(data));
+ },
+ [dispatch]
+ );
+ const url = `ws://${window.envUrl}/api/v1/posting/query/ws`;
+ useWebsocket(url, [{ eventName: "default", onEvent: onPost }]);
+};
diff --git a/frontend/src/websocket/websocketProvider.tsx b/frontend/src/websocket/websocketProvider.tsx
index 88f3d17..f0d01c2 100644
--- a/frontend/src/websocket/websocketProvider.tsx
+++ b/frontend/src/websocket/websocketProvider.tsx
@@ -1,45 +1,50 @@
-import { addPostSync } from "@/statemanagement/posting/postSlice";
-import { useAppDispatch, useAppSelector } from "@/statemanagement/store";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useAppSelector } from "@/statemanagement/store";
+import { useEffect } from "react";
-interface WebsocketProviderProps {
- children: React.ReactNode;
-}
+export const useWebsocket = (
+ url: string,
+ listenToEvents: { eventName: string; onEvent: (data: unknown) => void }[]
+) => {
+ const token = useAppSelector((state) => state.authentication.token) || "";
-export const WebsocketProvider = ({ children }: WebsocketProviderProps) => {
- const url = `ws://${window.envUrl}/api/v1/posting/query/ws`;
- const token = useAppSelector((state) => state.authentication.token);
- const dispatch = useAppDispatch();
- const [newWebSocket, setNewWebSocket] = useState(0);
- const ws = useMemo(() => {
- return new WebSocket(url);
- }, [newWebSocket]);
-
- const onPost = useCallback(
- (data: any) => {
- console.log(data);
- dispatch(addPostSync(data));
- },
- [ws, dispatch]
- );
useEffect(() => {
+ websocketInitializer(url, token, listenToEvents);
+ }, [url, token]);
+};
+
+const websocketInitializer = (
+ url: string,
+ token: string,
+ listenToEvents: { eventName: string; onEvent: (data: unknown) => void }[]
+) => {
+ const initWebsocket = () => {
+ const ws = new WebSocket(url);
+ let isReconnecting = false;
ws.onmessage = (event) => {
- onPost(JSON.parse(event.data));
+ listenToEvents.forEach((listenDef) => {
+ if (listenDef.eventName === "default") {
+ //todo add event name to the event
+ listenDef.onEvent(JSON.parse(event.data));
+ }
+ });
};
ws.onopen = () => {
ws.send(JSON.stringify({ token: token, data: "" }));
};
+ const reconnect = () => {
+ if (!isReconnecting) {
+ isReconnecting = true;
+ setTimeout(() => {
+ initWebsocket();
+ }, 500);
+ }
+ };
ws.onclose = () => {
- setTimeout(() => {
- setNewWebSocket(newWebSocket + 1);
- }, 500);
+ reconnect();
};
ws.onerror = () => {
- setTimeout(() => {
- setNewWebSocket(newWebSocket + 1);
- }, 500);
+ reconnect();
};
- }, [ws, newWebSocket, onPost]);
-
- return <>{children}>;
+ };
+ initWebsocket();
};
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 8b89c8b..e3d068d 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -301,12 +301,12 @@
dependencies:
"@radix-ui/react-primitive" "2.0.0"
-"@radix-ui/react-avatar@^1.1.0":
- version "1.1.0"
- resolved "https://registry.yarnpkg.com/@radix-ui/react-avatar/-/react-avatar-1.1.0.tgz#457c81334c93f4608df15f081e7baa286558d6a2"
- integrity sha512-Q/PbuSMk/vyAd/UoIShVGZ7StHHeRFYU7wXmi5GV+8cLXflZAEpHL/F697H1klrzxKXNtZ97vWiC0q3RKUH8UA==
+"@radix-ui/react-avatar@^1.1.1":
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-avatar/-/react-avatar-1.1.1.tgz#5848d2ed5f34d18b36fc7e2d227c41fca8600ea1"
+ integrity sha512-eoOtThOmxeoizxpX6RiEsQZ2wj5r4+zoeqAwO0cBaFQGjJwIH3dIX0OCxNrCyrrdxG+vBweMETh3VziQG7c1kw==
dependencies:
- "@radix-ui/react-context" "1.1.0"
+ "@radix-ui/react-context" "1.1.1"
"@radix-ui/react-primitive" "2.0.0"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-layout-effect" "1.1.0"
@@ -381,6 +381,11 @@
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz#8e9abb472a9a394f59a1b45f3dd26cfe3fc6da13"
integrity sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==
+"@radix-ui/react-focus-guards@1.1.1":
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz#8635edd346304f8b42cae86b05912b61aef27afe"
+ integrity sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==
+
"@radix-ui/react-focus-scope@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz#ebe2891a298e0a33ad34daab2aad8dea31caf0b2"
@@ -433,6 +438,27 @@
aria-hidden "^1.1.1"
react-remove-scroll "2.5.7"
+"@radix-ui/react-popover@^1.1.2":
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.2.tgz#a0cab25f69aa49ad0077d91e9e9dcd323758020c"
+ integrity sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==
+ dependencies:
+ "@radix-ui/primitive" "1.1.0"
+ "@radix-ui/react-compose-refs" "1.1.0"
+ "@radix-ui/react-context" "1.1.1"
+ "@radix-ui/react-dismissable-layer" "1.1.1"
+ "@radix-ui/react-focus-guards" "1.1.1"
+ "@radix-ui/react-focus-scope" "1.1.0"
+ "@radix-ui/react-id" "1.1.0"
+ "@radix-ui/react-popper" "1.2.0"
+ "@radix-ui/react-portal" "1.1.2"
+ "@radix-ui/react-presence" "1.1.1"
+ "@radix-ui/react-primitive" "2.0.0"
+ "@radix-ui/react-slot" "1.1.0"
+ "@radix-ui/react-use-controllable-state" "1.1.0"
+ aria-hidden "^1.1.1"
+ react-remove-scroll "2.6.0"
+
"@radix-ui/react-popper@1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.0.tgz#a3e500193d144fe2d8f5d5e60e393d64111f2a7a"
@@ -1909,7 +1935,7 @@ react-redux@^9.1.2:
"@types/use-sync-external-store" "^0.0.3"
use-sync-external-store "^1.0.0"
-react-remove-scroll-bar@^2.3.4:
+react-remove-scroll-bar@^2.3.4, react-remove-scroll-bar@^2.3.6:
version "2.3.6"
resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.6.tgz#3e585e9d163be84a010180b18721e851ac81a29c"
integrity sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==
@@ -1928,6 +1954,17 @@ react-remove-scroll@2.5.7:
use-callback-ref "^1.3.0"
use-sidecar "^1.1.2"
+react-remove-scroll@2.6.0:
+ version "2.6.0"
+ resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz#fb03a0845d7768a4f1519a99fdb84983b793dc07"
+ integrity sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==
+ dependencies:
+ react-remove-scroll-bar "^2.3.6"
+ react-style-singleton "^2.2.1"
+ tslib "^2.1.0"
+ use-callback-ref "^1.3.0"
+ use-sidecar "^1.1.2"
+
react-router-dom@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.0.1.tgz#b1438100800313e1b4c48da0c5fdb498f81c7f96"