From 9be79b1481aacdc870de3e92bc4cb7ea447c70ff Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 12 Sep 2024 20:06:10 +0200 Subject: [PATCH] added questions, reply and like functionality" --- public/index.html | 2 +- src/core/reducers/appSlice.tsx | 96 ++-- src/core/services/cachedUser.ts | 30 ++ src/core/services/lessons.ts | 38 +- src/core/services/userProfile.ts | 10 +- src/core/services/websocketService.ts | 495 ++++++++++-------- src/core/store/store.tsx | 2 + src/core/types/lesson.ts | 16 +- src/core/types/userProfile.ts | 7 + src/core/utils/webSocket.ts | 5 + src/features/ContactSupport/index.tsx | 35 +- src/features/Lessons/LessonPage/index.tsx | 2 +- src/features/Lessons/Questions/index.tsx | 464 ++++++++-------- .../Lessons/Questions/lessonQuestionSlice.ts | 85 ++- 14 files changed, 757 insertions(+), 530 deletions(-) create mode 100644 src/core/services/cachedUser.ts diff --git a/public/index.html b/public/index.html index aa069f2..b4fc862 100644 --- a/public/index.html +++ b/public/index.html @@ -24,7 +24,7 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - React App + App diff --git a/src/core/reducers/appSlice.tsx b/src/core/reducers/appSlice.tsx index fbddf49..e9e2e4e 100644 --- a/src/core/reducers/appSlice.tsx +++ b/src/core/reducers/appSlice.tsx @@ -1,45 +1,67 @@ -import { createSlice } from '@reduxjs/toolkit'; +import { createSlice } from "@reduxjs/toolkit"; +import { CachedUser } from "core/types/userProfile"; export const appSlice = createSlice({ - name: 'app', - initialState: { - darkMode: false, - userAuthenticated: null, - userProfilePictureUrl: null, - primaryColor: '#111', - logoUrl: null, - bannerUrl: null, + name: "app", + initialState: { + darkMode: false, + userAuthenticated: null, + userProfilePictureUrl: null, + primaryColor: "#111", + logoUrl: null, + bannerUrl: null, + cachedUsers: {} as Record, + }, + reducers: { + setDarkMode: (state, action) => { + state.darkMode = action.payload; }, - reducers: { - setDarkMode: (state, action) => { - state.darkMode = action.payload; - }, - setUserAuthenticated: (state, action) => { - state.userAuthenticated = action.payload; - }, - setUserProfilePictureUrl: (state, action) => { - state.userProfilePictureUrl = action.payload; - }, - setPrimaryColor: (state, action) => { - state.primaryColor = action.payload; - }, - setLogoUrl: (state, action) => { - state.logoUrl = action.payload; - }, - setBannerUrl: (state, action) => { - state.bannerUrl = action.payload; - }, + setUserAuthenticated: (state, action) => { + state.userAuthenticated = action.payload; }, - selectors: { - darkMode: (state) => state.darkMode, - userAuthenticated: (state) => state.userAuthenticated, - userProfilePictureUrl: (state) => state.userProfilePictureUrl, - primaryColor: (state) => state.primaryColor, - logoUrl: (state) => state.logoUrl, - bannerUrl: (state) => state.bannerUrl, + setUserProfilePictureUrl: (state, action) => { + state.userProfilePictureUrl = action.payload; }, + setPrimaryColor: (state, action) => { + state.primaryColor = action.payload; + }, + setLogoUrl: (state, action) => { + state.logoUrl = action.payload; + }, + setBannerUrl: (state, action) => { + state.bannerUrl = action.payload; + }, + addUserToCache: (state, action) => { + state.cachedUsers[action.payload.id] = action.payload; + }, + }, + selectors: { + darkMode: (state) => state.darkMode, + userAuthenticated: (state) => state.userAuthenticated, + userProfilePictureUrl: (state) => state.userProfilePictureUrl, + primaryColor: (state) => state.primaryColor, + logoUrl: (state) => state.logoUrl, + bannerUrl: (state) => state.bannerUrl, + cachedUsers: (state) => state.cachedUsers, + }, }); -export const { setDarkMode, setUserAuthenticated, setUserProfilePictureUrl, setPrimaryColor, setLogoUrl, setBannerUrl } = appSlice.actions; +export const { + setDarkMode, + setUserAuthenticated, + setUserProfilePictureUrl, + setPrimaryColor, + setLogoUrl, + setBannerUrl, + addUserToCache, +} = appSlice.actions; -export const { darkMode, userAuthenticated, userProfilePictureUrl, primaryColor, logoUrl, bannerUrl } = appSlice.selectors; +export const { + darkMode, + userAuthenticated, + userProfilePictureUrl, + primaryColor, + logoUrl, + bannerUrl, + cachedUsers, +} = appSlice.selectors; diff --git a/src/core/services/cachedUser.ts b/src/core/services/cachedUser.ts new file mode 100644 index 0000000..923ba91 --- /dev/null +++ b/src/core/services/cachedUser.ts @@ -0,0 +1,30 @@ +import { useDispatch, useSelector } from "react-redux"; +import { useGetUserQuery } from "./userProfile"; +import { addUserToCache, cachedUsers } from "core/reducers/appSlice"; +import { CachedUser } from "core/types/userProfile"; +import { useEffect } from "react"; + +export function useCachedUser(userId: string) { + const dispatch = useDispatch(); + const cachedUser = useSelector(cachedUsers)[userId] as CachedUser; + + const { + data: user, + error, + isLoading, + } = useGetUserQuery(userId, { + skip: !!cachedUser, // Skip the query if the user is already cached + }); + + useEffect(() => { + if (user) { + dispatch(addUserToCache(user)); + } + }, [user]); + + return { + user: cachedUser || user, + error, + isLoading, + }; +} diff --git a/src/core/services/lessons.ts b/src/core/services/lessons.ts index 479dbcd..7b3f44a 100644 --- a/src/core/services/lessons.ts +++ b/src/core/services/lessons.ts @@ -5,6 +5,7 @@ import { LessonContent, LessonQuestion, LessonSettings, + QuestionsResponse, UpdateLessonPreviewThumbnail, } from "core/types/lesson"; @@ -94,12 +95,42 @@ export const lessonsApi = createApi({ method: "DELETE", }), }), - getQuestions: builder.query({ + getLessonQuestions: builder.query({ query: (lessonId) => ({ url: `lessons/${lessonId}/questions`, method: "GET", }), }), + createLessonQuestion: builder.mutation({ + query: ({ lessonId, message }) => ({ + url: `lessons/${lessonId}/questions`, + method: "POST", + body: { + Message: message, + }, + }), + }), + createLessonQuestionReply: builder.mutation({ + query: ({ lessonId, questionId, message }) => ({ + url: `lessons/${lessonId}/questions/${questionId}/replies`, + method: "POST", + body: { + Message: message, + }, + }), + }), + likeQuestion: builder.mutation({ + query: ({ lessonId, questionId }) => ({ + url: `lessons/${lessonId}/questions/${questionId}/likes`, + method: "POST", + }), + }), + dislikeQuestion: builder.mutation({ + query: ({ lessonId, questionId }) => ({ + url: `lessons/${lessonId}/questions/${questionId}/likes`, + method: "DELETE", + }), + }), }), }); @@ -115,4 +146,9 @@ export const { useUpdateLessonContentMutation, useUpdateLessonContentPositionMutation, useDeleteLessonContentMutation, + useGetLessonQuestionsQuery, + useCreateLessonQuestionMutation, + useCreateLessonQuestionReplyMutation, + useLikeQuestionMutation, + useDislikeQuestionMutation, } = lessonsApi; diff --git a/src/core/services/userProfile.ts b/src/core/services/userProfile.ts index 79aa09f..a2d75ef 100644 --- a/src/core/services/userProfile.ts +++ b/src/core/services/userProfile.ts @@ -1,6 +1,6 @@ import { createApi } from "@reduxjs/toolkit/query/react"; import { baseQueryWithErrorHandling } from "core/helper/api"; -import { UserProfile } from "core/types/userProfile"; +import { CachedUser, UserProfile } from "core/types/userProfile"; export const userProfileApi = createApi({ reducerPath: "userProfileApi", @@ -12,7 +12,13 @@ export const userProfileApi = createApi({ method: "GET", }), }), + getUser: builder.query({ + query: (userId) => ({ + url: `user/${userId}`, + method: "GET", + }), + }), }), }); -export const { useGetUserProfileQuery } = userProfileApi; +export const { useGetUserProfileQuery, useGetUserQuery } = userProfileApi; diff --git a/src/core/services/websocketService.ts b/src/core/services/websocketService.ts index 28d3b84..b287582 100644 --- a/src/core/services/websocketService.ts +++ b/src/core/services/websocketService.ts @@ -1,255 +1,312 @@ -import { Dispatch } from '@reduxjs/toolkit'; -import { setBannerUrl, setLogoUrl, setPrimaryColor, setUserProfilePictureUrl } from 'core/reducers/appSlice'; -import { store } from 'core/store/store'; -import { BrowserTabSession, Constants } from 'core/utils/utils'; -import { WebSocketReceivedMessagesCmds } from 'core/utils/webSocket'; -import { addLessonPageContent, deleteLessonPageContent, updateLessonPageContent, updateLessonPageContentPosition } from 'features/Lessons/LessonPage/lessonPageSlice'; +import { Dispatch } from "@reduxjs/toolkit"; import { - addLessonContent, - deleteLessonContent, - setLessonThumbnailTitle, - setLessonThumbnailUrl, - setPageEditorLessonState, - updateLessonContent, - updateLessonContentPosition, -} from 'features/Lessons/LessonPageEditor/lessonPageEditorSlice'; -import { addLesson, updateLessonPreviewThumbnail, updateLessonPreviewTitle, updateLessonState } from 'features/Lessons/lessonsSlice'; -import { addTeamMember, deleteTeamMember, updateTeamMemberRole } from 'features/Team/teamSlice'; -import { setProfilePictureUrl } from 'features/UserProfile/userProfileSlice'; + setBannerUrl, + setLogoUrl, + setPrimaryColor, + setUserProfilePictureUrl, +} from "core/reducers/appSlice"; +import { store } from "core/store/store"; +import { BrowserTabSession, Constants } from "core/utils/utils"; +import { WebSocketReceivedMessagesCmds } from "core/utils/webSocket"; +import { + addLessonPageContent, + deleteLessonPageContent, + updateLessonPageContent, + updateLessonPageContentPosition, +} from "features/Lessons/LessonPage/lessonPageSlice"; +import { + addLessonContent, + deleteLessonContent, + setLessonThumbnailTitle, + setLessonThumbnailUrl, + setPageEditorLessonState, + updateLessonContent, + updateLessonContentPosition, +} from "features/Lessons/LessonPageEditor/lessonPageEditorSlice"; +import { + addLesson, + updateLessonPreviewThumbnail, + updateLessonPreviewTitle, + updateLessonState, +} from "features/Lessons/lessonsSlice"; +import { + addLikedQuestion, + addQuestion, + countDownLikedQuestion, + countUpLikedQuestion, + deleteLikedQuestion, +} from "features/Lessons/Questions/lessonQuestionSlice"; +import { + addTeamMember, + deleteTeamMember, + updateTeamMemberRole, +} from "features/Team/teamSlice"; +import { setProfilePictureUrl } from "features/UserProfile/userProfileSlice"; interface WebSocketMessage { - Cmd: number; - Body: any; + Cmd: number; + Body: any; } class WebSocketService { - private url: string; - private socket: WebSocket | null = null; - private reconnectInterval: number = 2000; // in ms - private offlineQueue: WebSocketMessage[] = []; - private firstConnect: boolean = true; + private url: string; + private socket: WebSocket | null = null; + private reconnectInterval: number = 2000; // in ms + private offlineQueue: WebSocketMessage[] = []; + private firstConnect: boolean = true; - private messageHandler: ((message: WebSocketMessage, dispatch: Dispatch) => void) | null = null; + private messageHandler: + | ((message: WebSocketMessage, dispatch: Dispatch) => void) + | null = null; - constructor(url: string) { - this.url = url; + constructor(url: string) { + this.url = url; + } + + private dispatch: Dispatch | null = null; + + public connect(): void { + this.socket = new WebSocket( + `${this.url}?auth=${localStorage.getItem( + "session" + )}&bts=${BrowserTabSession}` + ); + + this.socket.onopen = () => { + console.log("WebSocket connected", this.firstConnect); + + // Send all messages from the offline queue + + this.offlineQueue.forEach((message) => this.send(message)); + this.offlineQueue = []; + + // Dispatch event to notify that the WebSocket connection is established + + if (!this.firstConnect) { + document.dispatchEvent(webSocketConnectionEvent); + } else { + this.firstConnect = false; + } + }; + + this.socket.onmessage = (event: MessageEvent) => { + const data: WebSocketMessage = JSON.parse(event.data); + + if (this.messageHandler) { + this.messageHandler(data, this.dispatch!); + } else { + console.error("No handler defined for WebSocket messages"); + } + }; + + this.socket.onclose = () => { + console.log("WebSocket disconnected. Reconnecting..."); + setTimeout(() => this.connect(), this.reconnectInterval); + }; + + this.socket.onerror = (error: Event) => { + console.error("WebSocket error:", error); + }; + } + + public setHandler( + handler: (message: WebSocketMessage, dispatch: Dispatch) => void, + dispatch: Dispatch + ): void { + this.messageHandler = handler; + this.dispatch = dispatch; + } + + public send(message: WebSocketMessage): void { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + this.socket.send( + JSON.stringify({ + Cmd: message.Cmd, + Body: message.Body, + }) + ); + } else { + this.offlineQueue.push(message); } + } - private dispatch: Dispatch | null = null; - - public connect(): void { - this.socket = new WebSocket(`${this.url}?auth=${localStorage.getItem('session')}&bts=${BrowserTabSession}`); - - this.socket.onopen = () => { - console.log('WebSocket connected', this.firstConnect); - - // Send all messages from the offline queue - - this.offlineQueue.forEach((message) => this.send(message)); - this.offlineQueue = []; - - // Dispatch event to notify that the WebSocket connection is established - - if (!this.firstConnect) { - document.dispatchEvent(webSocketConnectionEvent); - } else { - this.firstConnect = false; - } - }; - - this.socket.onmessage = (event: MessageEvent) => { - const data: WebSocketMessage = JSON.parse(event.data); - - if (this.messageHandler) { - this.messageHandler(data, this.dispatch!); - } else { - console.error('No handler defined for WebSocket messages'); - } - }; - - this.socket.onclose = () => { - console.log('WebSocket disconnected. Reconnecting...'); - setTimeout(() => this.connect(), this.reconnectInterval); - }; - - this.socket.onerror = (error: Event) => { - console.error('WebSocket error:', error); - }; - } - - public setHandler(handler: (message: WebSocketMessage, dispatch: Dispatch) => void, dispatch: Dispatch): void { - this.messageHandler = handler; - this.dispatch = dispatch; - } - - public send(message: WebSocketMessage): void { - if (this.socket && this.socket.readyState === WebSocket.OPEN) { - this.socket.send( - JSON.stringify({ - Cmd: message.Cmd, - Body: message.Body, - }) - ); - } else { - this.offlineQueue.push(message); - } - } - - public disconnect(): void { - if (this.socket) { - this.socket.close(); - } + public disconnect(): void { + if (this.socket) { + this.socket.close(); } + } } -const webSocketConnectionEventName = 'WebSocketConnectionEvent'; +const webSocketConnectionEventName = "WebSocketConnectionEvent"; const webSocketConnectionEvent = new CustomEvent(webSocketConnectionEventName, { - detail: 'wsReconnect', + detail: "wsReconnect", }); export function addWebSocketReconnectListener(callback: () => void): void { - document.addEventListener(webSocketConnectionEventName, callback); + document.addEventListener(webSocketConnectionEventName, callback); } export function removeWebSocketReconnectListener(callback: () => void): void { - document.removeEventListener(webSocketConnectionEventName, callback); + document.removeEventListener(webSocketConnectionEventName, callback); } const webSocketService = new WebSocketService(Constants.WS_ADDRESS); export default webSocketService; -export function WebSocketMessageHandler(message: WebSocketMessage, dispatch: Dispatch) { - const { Cmd, Body } = message; +export function WebSocketMessageHandler( + message: WebSocketMessage, + dispatch: Dispatch +) { + const { Cmd, Body } = message; - console.log('WebSocketMessageHandler', Cmd, Body); + console.log("WebSocketMessageHandler", Cmd, Body); - switch (Cmd) { - case WebSocketReceivedMessagesCmds.SettingsUpdated: - dispatch(setPrimaryColor(Body.PrimaryColor)); - break; - case WebSocketReceivedMessagesCmds.SettingsUpdatedLogo: - dispatch(setLogoUrl(Body)); - break; - case WebSocketReceivedMessagesCmds.SettingsUpdatedBanner: - dispatch(setBannerUrl(Body)); - break; - case WebSocketReceivedMessagesCmds.SettingsUpdatedSubdomain: - localStorage.removeItem('session'); + switch (Cmd) { + case WebSocketReceivedMessagesCmds.SettingsUpdated: + dispatch(setPrimaryColor(Body.PrimaryColor)); + break; + case WebSocketReceivedMessagesCmds.SettingsUpdatedLogo: + dispatch(setLogoUrl(Body)); + break; + case WebSocketReceivedMessagesCmds.SettingsUpdatedBanner: + dispatch(setBannerUrl(Body)); + break; + case WebSocketReceivedMessagesCmds.SettingsUpdatedSubdomain: + localStorage.removeItem("session"); - window.location.href = `${window.location.protocol}//${Body}.${window.location.hostname.split('.').slice(1).join('.')}`; - break; - case WebSocketReceivedMessagesCmds.TeamAddedMember: - dispatch(addTeamMember(Body)); - break; - case WebSocketReceivedMessagesCmds.TeamUpdatedMemberRole: - dispatch(updateTeamMemberRole(Body)); - break; - case WebSocketReceivedMessagesCmds.TeamDeletedMember: - dispatch(deleteTeamMember(Body)); - break; - case WebSocketReceivedMessagesCmds.LessonCreated: - dispatch(addLesson(Body)); - break; - case WebSocketReceivedMessagesCmds.LessonPreviewTitleUpdated: - dispatch(updateLessonPreviewTitle(Body)); + window.location.href = `${ + window.location.protocol + }//${Body}.${window.location.hostname.split(".").slice(1).join(".")}`; + break; + case WebSocketReceivedMessagesCmds.TeamAddedMember: + dispatch(addTeamMember(Body)); + break; + case WebSocketReceivedMessagesCmds.TeamUpdatedMemberRole: + dispatch(updateTeamMemberRole(Body)); + break; + case WebSocketReceivedMessagesCmds.TeamDeletedMember: + dispatch(deleteTeamMember(Body)); + break; + case WebSocketReceivedMessagesCmds.LessonCreated: + dispatch(addLesson(Body)); + break; + case WebSocketReceivedMessagesCmds.LessonPreviewTitleUpdated: + dispatch(updateLessonPreviewTitle(Body)); - if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { - dispatch(setLessonThumbnailTitle(Body.Title)); - } - break; - case WebSocketReceivedMessagesCmds.LessonPreviewThumbnailUpdated: - dispatch(updateLessonPreviewThumbnail(Body)); + if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { + dispatch(setLessonThumbnailTitle(Body.Title)); + } + break; + case WebSocketReceivedMessagesCmds.LessonPreviewThumbnailUpdated: + dispatch(updateLessonPreviewThumbnail(Body)); - if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { - dispatch(setLessonThumbnailUrl(Body.ThumbnailUrl)); - } - break; - case WebSocketReceivedMessagesCmds.LessonStateUpdated: - dispatch(updateLessonState(Body)); + if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { + dispatch(setLessonThumbnailUrl(Body.ThumbnailUrl)); + } + break; + case WebSocketReceivedMessagesCmds.LessonStateUpdated: + dispatch(updateLessonState(Body)); - if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { - dispatch(setPageEditorLessonState(Body.State)); - } - break; - case WebSocketReceivedMessagesCmds.LessonAddedContent: - if (Body.LessonId === store.getState().lessonPage.currentLessonId) { - dispatch(addLessonPageContent(Body)); - } - if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { - dispatch(addLessonContent(Body)); - } - break; - case WebSocketReceivedMessagesCmds.LessonDeletedContent: - if (Body.LessonId === store.getState().lessonPage.currentLessonId) { - dispatch(deleteLessonPageContent(Body.ContentId)); - } - if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { - dispatch(deleteLessonContent(Body.ContentId)); - } - break; - case WebSocketReceivedMessagesCmds.LessonContentUpdated: - if (Body.LessonId === store.getState().lessonPage.currentLessonId) { - dispatch( - updateLessonPageContent({ - id: Body.ContentId, - data: Body.Data, - }) - ); - } + if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { + dispatch(setPageEditorLessonState(Body.State)); + } + break; + case WebSocketReceivedMessagesCmds.LessonAddedContent: + if (Body.LessonId === store.getState().lessonPage.currentLessonId) { + dispatch(addLessonPageContent(Body)); + } + if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { + dispatch(addLessonContent(Body)); + } + break; + case WebSocketReceivedMessagesCmds.LessonDeletedContent: + if (Body.LessonId === store.getState().lessonPage.currentLessonId) { + dispatch(deleteLessonPageContent(Body.ContentId)); + } + if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { + dispatch(deleteLessonContent(Body.ContentId)); + } + break; + case WebSocketReceivedMessagesCmds.LessonContentUpdated: + if (Body.LessonId === store.getState().lessonPage.currentLessonId) { + dispatch( + updateLessonPageContent({ + id: Body.ContentId, + data: Body.Data, + }) + ); + } - if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { - dispatch( - updateLessonContent({ - id: Body.ContentId, - data: Body.Data, - }) - ); - } - break; - case WebSocketReceivedMessagesCmds.LessonContentUpdatedPosition: - if (Body.LessonId === store.getState().lessonPage.currentLessonId) { - dispatch( - updateLessonPageContentPosition({ - contentId: Body.ContentId, - position: Body.Position, - }) - ); - } + if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { + dispatch( + updateLessonContent({ + id: Body.ContentId, + data: Body.Data, + }) + ); + } + break; + case WebSocketReceivedMessagesCmds.LessonContentUpdatedPosition: + if (Body.LessonId === store.getState().lessonPage.currentLessonId) { + dispatch( + updateLessonPageContentPosition({ + contentId: Body.ContentId, + position: Body.Position, + }) + ); + } - if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { - dispatch( - updateLessonContentPosition({ - contentId: Body.ContentId, - position: Body.Position, - }) - ); - } - break; - case WebSocketReceivedMessagesCmds.LessonContentFileUpdated: - if (Body.LessonId === store.getState().lessonPage.currentLessonId) { - dispatch( - updateLessonPageContent({ - id: Body.ContentId, - data: Body.Data, - }) - ); - } + if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { + dispatch( + updateLessonContentPosition({ + contentId: Body.ContentId, + position: Body.Position, + }) + ); + } + break; + case WebSocketReceivedMessagesCmds.LessonContentFileUpdated: + if (Body.LessonId === store.getState().lessonPage.currentLessonId) { + dispatch( + updateLessonPageContent({ + id: Body.ContentId, + data: Body.Data, + }) + ); + } - if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { - dispatch( - updateLessonContent({ - id: Body.ContentId, - data: Body.Data, - }) - ); - } - break; - case WebSocketReceivedMessagesCmds.UserProfilePictureUpdated: - dispatch(setProfilePictureUrl(Body)); - dispatch(setUserProfilePictureUrl(Body)); - break; - default: - console.error('Unknown message type:', Cmd); - } + if (Body.LessonId === store.getState().lessonPageEditor.currentLessonId) { + dispatch( + updateLessonContent({ + id: Body.ContentId, + data: Body.Data, + }) + ); + } + break; + case WebSocketReceivedMessagesCmds.UserProfilePictureUpdated: + dispatch(setProfilePictureUrl(Body)); + dispatch(setUserProfilePictureUrl(Body)); + break; + case WebSocketReceivedMessagesCmds.LessonQuestionCreated: + if (Body.LessonId === store.getState().lessonPage.currentLessonId) { + dispatch(addQuestion(Body)); + } + break; + case WebSocketReceivedMessagesCmds.LessonQuestionLiked: + dispatch(addLikedQuestion(Body)); + break; + case WebSocketReceivedMessagesCmds.LessonQuestionCountUpLikes: + dispatch(countUpLikedQuestion(Body)); + break; + case WebSocketReceivedMessagesCmds.LessonQuestionDisliked: + dispatch(deleteLikedQuestion(Body)); + break; + case WebSocketReceivedMessagesCmds.LessonQuestionCountDownLikes: + dispatch(countDownLikedQuestion(Body)); + break; + default: + console.error("Unknown message type:", Cmd); + } } diff --git a/src/core/store/store.tsx b/src/core/store/store.tsx index 6c5a7b5..80198de 100644 --- a/src/core/store/store.tsx +++ b/src/core/store/store.tsx @@ -10,6 +10,7 @@ import { lessonsSlice } from "features/Lessons/lessonsSlice"; import { lessonPageSlice } from "features/Lessons/LessonPage/lessonPageSlice"; import { userProfileApi } from "core/services/userProfile"; import { userProfileSlice } from "features/UserProfile/userProfileSlice"; +import { lessonQuestionsSlice } from "features/Lessons/Questions/lessonQuestionSlice"; const makeStore = (/* preloadedState */) => { const store = configureStore({ @@ -21,6 +22,7 @@ const makeStore = (/* preloadedState */) => { [lessonsApi.reducerPath]: lessonsApi.reducer, [lessonsSlice.reducerPath]: lessonsSlice.reducer, [lessonPageSlice.reducerPath]: lessonPageSlice.reducer, + [lessonQuestionsSlice.reducerPath]: lessonQuestionsSlice.reducer, [organizationApi.reducerPath]: organizationApi.reducer, [teamSlice.reducerPath]: teamSlice.reducer, [userProfileApi.reducerPath]: userProfileApi.reducer, diff --git a/src/core/types/lesson.ts b/src/core/types/lesson.ts index 8ad1a9f..4fdee43 100644 --- a/src/core/types/lesson.ts +++ b/src/core/types/lesson.ts @@ -36,20 +36,16 @@ export interface UpdateLessonPreviewThumbnail { export interface LessonQuestion { Id: string; LessionId: string; - Question: string; + QuestionId?: string; + ReplyId?: string; + Message: string; Likes: number; CreatorUserId: string; CreatedAt: string; UpdatedAt: string; } -export interface LessonQuestionReply { - Id: string; - QuestionId: string; - ReplyId?: string; - Reply: string; - Likes: number; - CreatorUserId: string; - CreatedAt: string; - UpdatedAt: string; +export interface QuestionsResponse { + Questions: LessonQuestion[]; + LikedQuestions: string[]; } diff --git a/src/core/types/userProfile.ts b/src/core/types/userProfile.ts index 1bde012..faf1fe5 100644 --- a/src/core/types/userProfile.ts +++ b/src/core/types/userProfile.ts @@ -5,3 +5,10 @@ export interface UserProfile { Email: string; RoleId: string; } + +export interface CachedUser { + Id: string; + FirstName: string; + LastName: string; + ProfilePictureUrl: string; +} diff --git a/src/core/utils/webSocket.ts b/src/core/utils/webSocket.ts index 8839833..8149b7b 100644 --- a/src/core/utils/webSocket.ts +++ b/src/core/utils/webSocket.ts @@ -20,6 +20,11 @@ enum WebSocketReceivedMessagesCmds { LessonContentUpdatedPosition = 15, LessonContentFileUpdated = 16, UserProfilePictureUpdated = 17, + LessonQuestionCreated = 18, + LessonQuestionLiked = 19, + LessonQuestionCountUpLikes = 20, + LessonQuestionDisliked = 21, + LessonQuestionCountDownLikes = 22, } export { WebSocketSendMessagesCmds, WebSocketReceivedMessagesCmds }; diff --git a/src/features/ContactSupport/index.tsx b/src/features/ContactSupport/index.tsx index 974eb0b..09fd718 100644 --- a/src/features/ContactSupport/index.tsx +++ b/src/features/ContactSupport/index.tsx @@ -1,20 +1,23 @@ -import { Descriptions, Typography } from 'antd'; -import MyMiddleCard from 'shared/components/MyMiddleCard'; -import MyBanner from 'shared/components/MyBanner'; +import { Descriptions, Typography } from "antd"; +import MyMiddleCard from "shared/components/MyMiddleCard"; +import MyBanner from "shared/components/MyBanner"; export default function ContactSupport() { - return ( - <> - + return ( + <> + - - If you have any questions or need help, please contact us at the following e-mail address: - - - support@jannex.de - - - - - ); + + + If you have any questions or need help, please contact us at the + following e-mail address: + + + + support@. + + + + + ); } diff --git a/src/features/Lessons/LessonPage/index.tsx b/src/features/Lessons/LessonPage/index.tsx index d0e1b66..6dea7f6 100644 --- a/src/features/Lessons/LessonPage/index.tsx +++ b/src/features/Lessons/LessonPage/index.tsx @@ -79,7 +79,7 @@ export default function LessonPage() { - + } > diff --git a/src/features/Lessons/Questions/index.tsx b/src/features/Lessons/Questions/index.tsx index d2dc7f1..54b5e11 100644 --- a/src/features/Lessons/Questions/index.tsx +++ b/src/features/Lessons/Questions/index.tsx @@ -1,267 +1,295 @@ import { HeartFilled, HeartOutlined } from "@ant-design/icons"; -import { Avatar, Button, Flex, Form, Input, InputRef, Typography } from "antd"; -import { LessonQuestion, LessonQuestionReply } from "core/types/lesson"; -import { Constants } from "core/utils/utils"; -import React, { useRef } from "react"; +import { Button, Flex, Form, Input, InputRef, Typography } from "antd"; +import { + useCreateLessonQuestionMutation, + useCreateLessonQuestionReplyMutation, + useDislikeQuestionMutation, + useGetLessonQuestionsQuery, + useLikeQuestionMutation, +} from "core/services/lessons"; +import { LessonQuestion } from "core/types/lesson"; +import { ReactNode, useEffect, useRef, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useParams } from "react-router-dom"; +import { + likedQuestions, + questions, + setLikedQuestions, + setQuestions, +} from "./lessonQuestionSlice"; +import MyCenteredSpin from "shared/components/MyCenteredSpin"; +import MyErrorResult from "shared/components/MyResult"; +import { useMessage } from "core/context/MessageContext"; +import { useForm } from "antd/es/form/Form"; +import { + addWebSocketReconnectListener, + removeWebSocketReconnectListener, +} from "core/services/websocketService"; +import { useCachedUser } from "core/services/cachedUser"; +import MyUserAvatar from "shared/components/MyUserAvatar"; -export default function Questions({ lessionID }: { lessionID: string }) { - let questions: LessonQuestion[] = [ +const CreateQuestionForm: React.FC = () => { + const { lessonId } = useParams(); + const [form] = useForm(); + + const [createQuestion, { isLoading }] = useCreateLessonQuestionMutation(); + const { success, error } = useMessage(); + + const handleCreateLessonQuestion = async () => { + try { + await createQuestion({ + lessonId: lessonId as string, + message: form.getFieldValue("message"), + }).unwrap(); + + success("Question created successfully"); + form.resetFields(); + } catch (err) { + console.error(err); + error("Failed to create question"); + } + }; + + return ( +
handleCreateLessonQuestion()} + requiredMark={false} + > + + + + + + + +
+ ); +}; + +export default function Questions() { + const dispatch = useDispatch(); + const { lessonId } = useParams(); + + const dataQuestions = useSelector(questions); + const dataLikedQuestions = useSelector(likedQuestions); + + const { data, error, isLoading, refetch } = useGetLessonQuestionsQuery( + lessonId as string, { - Id: "1", - LessionId: "1", - Question: "What is the capital of Germany?", - Likes: 5, - CreatorUserId: "1", - CreatedAt: "2021-09-01T12:00:00Z", - UpdatedAt: "2021-09-01T12:00:00Z", - }, - { - Id: "2", - LessionId: "1", - Question: "What is the capital of France?", - Likes: 3, - CreatorUserId: "2", - CreatedAt: "2021-09-01T12:00:00Z", - UpdatedAt: "2021-09-01T12:00:00Z", - }, - { - Id: "3", - LessionId: "1", - Question: "What is the capital of Italy?", - Likes: 2, - CreatorUserId: "3", - CreatedAt: "2021-09-01T12:00:00Z", - UpdatedAt: "2021-09-01T12:00:00Z", - }, - ]; + refetchOnMountOrArgChange: true, + } + ); + + useEffect(() => { + if (!data) return; + + dispatch(setQuestions(data.Questions)); + dispatch(setLikedQuestions(data.LikedQuestions)); + }, [data]); + + useEffect(() => { + addWebSocketReconnectListener(refetch); + + return () => removeWebSocketReconnectListener(refetch); + }, []); return ( Questions -
- - - - - - -
+ + + - {questions.map((question) => ( - - ))} + {error ? ( + + ) : isLoading ? ( + + ) : ( + dataQuestions + .filter((question) => question.QuestionId === "") + .sort((b, a) => a.CreatedAt.localeCompare(b.CreatedAt)) + .map((question) => ( + reply.QuestionId === question.Id + )} + likedQuestions={dataLikedQuestions} + /> + )) + )}
); } -type HandleReplyFunction = (text: string, replyID?: string) => Promise; +type HandleReplyFunction = (message: string, replyId?: string) => Promise; -export function QuestionItem({ question }: { question: LessonQuestion }) { - const [showReplies, setShowReplies] = React.useState(1); +export function QuestionItem({ + lessonId, + question, + replies, + likedQuestions, +}: { + lessonId: string; + question: LessonQuestion; + replies: LessonQuestion[]; + likedQuestions: string[]; +}) { + const [showReplies, setShowReplies] = useState(1); + const { success, error } = useMessage(); - let user = { - Id: "132154153613", - FirstName: "Anja", - LastName: "Blasinstroment", - }; + const [reqCreateQuestionReply] = useCreateLessonQuestionReplyMutation(); + const [reqLikeQuestion] = useLikeQuestionMutation(); + const [reqDislikeQuestion] = useDislikeQuestionMutation(); - let questionsReplys: LessonQuestionReply[] = [ - { - Id: "1", - QuestionId: "1", - Reply: "Berlin", - Likes: 5, - CreatorUserId: "1", - CreatedAt: "2021-09-01T12:00:00Z", - UpdatedAt: "2021-09-01T12:00:00Z", - }, - { - Id: "2", - QuestionId: "1", - Reply: "Munich", - Likes: 3, - CreatorUserId: "2", - CreatedAt: "2021-09-01T12:00:00Z", - UpdatedAt: "2021-09-01T12:00:00Z", - }, - { - Id: "3", - QuestionId: "1", - Reply: "Hamburg", - Likes: 2, - CreatorUserId: "3", - CreatedAt: "2021-09-01T12:00:00Z", - UpdatedAt: "2021-09-01T12:00:00Z", - }, - { - Id: "4", - QuestionId: "1", - Reply: "Cologne", - Likes: 0, - CreatorUserId: "3", - CreatedAt: "2021-09-01T12:00:00Z", - UpdatedAt: "2021-09-01T12:00:00Z", - }, - { - Id: "5", - QuestionId: "1", - Reply: "Frankfurt", - Likes: 0, - CreatorUserId: "3", - CreatedAt: "2021-09-01T12:00:00Z", - UpdatedAt: "2021-09-01T12:00:00Z", - }, - { - Id: "6", - QuestionId: "1", - Reply: "Stuttgart", - Likes: 2, - CreatorUserId: "3", - CreatedAt: "2021-09-01T12:00:00Z", - UpdatedAt: "2021-09-01T12:00:00Z", - }, - { - Id: "7", - QuestionId: "1", - Reply: "Düsseldorf", - Likes: 10, - CreatorUserId: "3", - CreatedAt: "2021-09-01T12:00:00Z", - UpdatedAt: "2021-09-01T12:00:00Z", - }, - ]; + async function handleReply(message: string, replyId?: string) { + try { + await reqCreateQuestionReply({ + lessonId: lessonId as string, + questionId: question.Id, + message: message, + }).unwrap(); - async function handleReply(text: string, replyID?: string) { - console.log("reply", text); - await new Promise((resolve) => setTimeout(resolve, 1000)); + success("Question created successfully"); + + await new Promise((resolve) => setTimeout(resolve, 0)); + } catch (err) { + console.error(err); + error("Failed to create question"); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + async function handleLike(questionId: string) { + try { + await reqLikeQuestion({ + lessonId: lessonId as string, + questionId: questionId, + }).unwrap(); + } catch (err) { + console.error(err); + error("Failed to like"); + } + } + + async function handleDislike(questionId: string) { + try { + await reqDislikeQuestion({ + lessonId: lessonId as string, + questionId: questionId, + }).unwrap(); + } catch (err) { + console.error(err); + error("Failed to dislike"); + } } return ( - {(() => { - let nodes = []; - - for (let i = 0; i < questionsReplys.length; i++) { - if (i > showReplies - 1) { - nodes.push( - - ); - break; - } - - nodes.push( - - ); - } - - return nodes; - })()} - + <> + {replies + .sort((a, b) => a.CreatedAt.localeCompare(b.CreatedAt)) + .map((reply) => ( + } + likes={reply.Likes} + onReply={handleReply} + onLike={() => handleLike(reply.Id)} + onDislike={() => handleDislike(reply.Id)} + replyId={reply.Id} + hasLiked={likedQuestions.some((likeId) => likeId === reply.Id)} + /> + ))} + } likes={question.Likes} onReply={handleReply} - onLike={() => {}} - replyID={undefined} - /> - ); -} - -export function QuestionReplyItem({ - question, - handleReply, -}: { - question: LessonQuestionReply; - handleReply: HandleReplyFunction; -}) { - let user = { - Id: "132154153613", - FirstName: "Anja", - LastName: "Blasinstroment", - }; - - return ( - } - likes={question.Likes} - onReply={handleReply} - onLike={() => {}} - replyID={question.Id} + onLike={() => handleLike(question.Id)} + onDislike={() => handleDislike(question.Id)} + replyId={undefined} + hasLiked={likedQuestions.some((likeId) => likeId === question.Id)} /> ); } export function QuestionUIRaw({ - userID, - text, + userId, + message, childContent, likes, - replyID, + replyId, onReply, onLike, + onDislike, + hasLiked, }: { - userID: string; - text: string; - childContent: React.ReactNode; + userId: string; + message: string; + childContent: ReactNode; likes: number; - replyID?: string; + replyId?: string; onReply: HandleReplyFunction; onLike: () => void; + onDislike: () => void; + hasLiked: boolean; }) { - const [hasLiked, setHasLiked] = React.useState(false); + //const [hasLiked, setHasLiked] = useState(false); - const [replyFormVisible, setReplyFormVisible] = React.useState(false); - const [replyText, setReplyText] = React.useState(null); - const [isSendingReply, setIsSendingReply] = React.useState(false); + const [replyFormVisible, setReplyFormVisible] = useState(false); + const [replyMessage, setReplyMessage] = useState(null); + const [isSendingReply, setIsSendingReply] = useState(false); - let user = { - Id: "132154153613", - FirstName: "Anja", - LastName: "Blasinstroment", - }; - - const userAt = `@${user.FirstName} ${user.LastName} `; - - async function toggleLike() { - setHasLiked(!hasLiked); - } + const { user, error, isLoading } = useCachedUser(userId); // useref to focus on the input field const inputRef = useRef(null); + const userAt = + isLoading || user === undefined + ? "" + : `@${user.FirstName} ${user.LastName} `; + + if (error) return ; + + if (isLoading) return ; + return ( <> - {user.FirstName} {user.LastName} - {text} + {message} { - if (replyText === null) setReplyText(userAt); + if (replyMessage === null) setReplyMessage(userAt); setReplyFormVisible(!replyFormVisible); setTimeout(() => { @@ -301,14 +329,17 @@ export function QuestionUIRaw({ {replyFormVisible ? (
{ setIsSendingReply(true); - await onReply(replyText ? replyText : "", replyID); + await onReply(replyMessage ? replyMessage : "", replyId); setIsSendingReply(false); setReplyFormVisible(false); - setReplyText(null); + setReplyMessage(null); }} > setReplyText(e.target.value)} + onChange={(e) => setReplyMessage(e.target.value)} + autoSize={{ minRows: 2 }} + maxLength={5000} /> diff --git a/src/features/Lessons/Questions/lessonQuestionSlice.ts b/src/features/Lessons/Questions/lessonQuestionSlice.ts index 166a138..d73133d 100644 --- a/src/features/Lessons/Questions/lessonQuestionSlice.ts +++ b/src/features/Lessons/Questions/lessonQuestionSlice.ts @@ -1,34 +1,65 @@ -import { createSlice } from '@reduxjs/toolkit'; -import { LessonContent, LessonState } from 'core/types/lesson'; +import { createSlice } from "@reduxjs/toolkit"; +import { LessonQuestion } from "core/types/lesson"; -interface AddLessonContentAction { - type: string; - payload: LessonContent; -} -/* -export const lessonPageEditorSlice = createSlice({ - name: 'lessonQuestions', - initialState: { - editorActive: false, - currentLessonId: '', // required in sideMenu because has no access to useParams - lessonThumbnail: { - img: '', - title: 'Tesdt', - }, - lessonContents: [] as LessonContent[], - lessonState: LessonState.Draft, +export const lessonQuestionsSlice = createSlice({ + name: "questions", + initialState: { + questions: [] as LessonQuestion[], + likedQuestions: [] as string[], + }, + reducers: { + setQuestions: (state, action) => { + state.questions = action.payload as LessonQuestion[]; }, - reducers: { - setEditorActive: (state, action) => { - state.editorActive = action.payload; - }, + addQuestion: (state, action) => { + state.questions.push(action.payload as LessonQuestion); }, - selectors: { - editorActive: (state) => state.editorActive, + setLikedQuestions: (state, action) => { + state.likedQuestions = action.payload as string[]; }, + addLikedQuestion: (state, action) => { + state.likedQuestions.push(action.payload as string); + }, + deleteLikedQuestion: (state, action) => { + const questionId = action.payload as string; + + state.likedQuestions = state.likedQuestions.filter( + (qId) => qId !== questionId + ); + }, + countUpLikedQuestion: (state, action) => { + const questionId = action.payload as string; + + const question = state.questions.find((q) => q.Id === questionId); + + if (question) { + question.Likes++; + } + }, + countDownLikedQuestion: (state, action) => { + const questionId = action.payload as string; + + const question = state.questions.find((q) => q.Id === questionId); + + if (question) { + question.Likes--; + } + }, + }, + selectors: { + questions: (state) => state.questions, + likedQuestions: (state) => state.likedQuestions, + }, }); -export const { setEditorActive } = lessonPageEditorSlice.actions; +export const { + setQuestions, + addQuestion, + setLikedQuestions, + addLikedQuestion, + deleteLikedQuestion, + countUpLikedQuestion, + countDownLikedQuestion, +} = lessonQuestionsSlice.actions; -export const { editorActive } = lessonPageEditorSlice.selectors; -*/ +export const { questions, likedQuestions } = lessonQuestionsSlice.selectors;