From cc6de79c2720747da5784ea7e5dec9087d680960 Mon Sep 17 00:00:00 2001 From: Thomas Mauran <78204354+thomas-mauran@users.noreply.github.com> Date: Fri, 8 Dec 2023 20:33:52 +0100 Subject: [PATCH] feat(messaging): messageChannel conversations (#26) * feat(messaging): messageChannel conversations Co-authored-by: Kuruyia <8174691+Kuruyia@users.noreply.github.com> --------- Signed-off-by: Mauran Co-authored-by: Kuruyia <8174691+Kuruyia@users.noreply.github.com> --- front/assets/translations/en_US.json | 3 +- front/assets/translations/fr_FR.json | 3 +- .../components/messaging/MessageBubble.tsx | 64 ++++++++++++++ .../messaging/MessageChannelContent.tsx | 51 +++++++++++ .../src/components/messaging/MessageList.tsx | 36 ++++++++ .../components/messaging/MessageTextInput.tsx | 62 +++++++++++++ .../models/dtos/messaging/createMessageDto.ts | 14 +++ front/src/models/entities/message.ts | 42 +++++++++ .../messaging/MessageChannelListPage.tsx | 16 +++- .../pages/messaging/MessageChannelPage.tsx | 87 +++++++++++++++++++ front/src/pages/messaging/MessagingNav.tsx | 9 ++ front/src/store/api/apiSlice.ts | 1 + front/src/store/api/messagingApiSlice.ts | 34 +++++++- mock/config/db.json | 75 +++++++++++++++- mock/config/routes.json | 1 - mock/index.js | 44 ++++++---- 16 files changed, 514 insertions(+), 28 deletions(-) create mode 100644 front/src/components/messaging/MessageBubble.tsx create mode 100644 front/src/components/messaging/MessageChannelContent.tsx create mode 100644 front/src/components/messaging/MessageList.tsx create mode 100644 front/src/components/messaging/MessageTextInput.tsx create mode 100644 front/src/models/dtos/messaging/createMessageDto.ts create mode 100644 front/src/models/entities/message.ts create mode 100644 front/src/pages/messaging/MessageChannelPage.tsx diff --git a/front/assets/translations/en_US.json b/front/assets/translations/en_US.json index f2cbf78..dfbc776 100644 --- a/front/assets/translations/en_US.json +++ b/front/assets/translations/en_US.json @@ -20,7 +20,8 @@ }, "messaging": { "info": { - "messageChannel": "Messages" + "messageChannel": "Messages", + "textInputPlaceholer": "Your message" } }, "profile": { diff --git a/front/assets/translations/fr_FR.json b/front/assets/translations/fr_FR.json index 376097e..ec21fea 100644 --- a/front/assets/translations/fr_FR.json +++ b/front/assets/translations/fr_FR.json @@ -20,7 +20,8 @@ }, "messaging": { "info": { - "messageChannel": "Messages" + "messageChannel": "Messages", + "textInputPlaceholer": "Votre message" } }, "profile": { diff --git a/front/src/components/messaging/MessageBubble.tsx b/front/src/components/messaging/MessageBubble.tsx new file mode 100644 index 0000000..e5bde92 --- /dev/null +++ b/front/src/components/messaging/MessageBubble.tsx @@ -0,0 +1,64 @@ +import { FC } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Text, useTheme } from 'react-native-paper'; + +import { Message, MessageDirection } from '@/models/entities/message'; + +/** + * The styles for the MessageBubble component. + */ +const styles = StyleSheet.create({ + message: { + borderRadius: 10, + padding: 10, + }, +}); + +/** + * The props for the MessageBubble component. + */ +type MessageBubbleProps = { + /** + * The message to display. + */ + message: Message; +}; + +/** + * Displays a message sent by a worker. + * @constructor + */ +const MessageBubble: FC = ({ message }) => { + const theme = useTheme(); + + const backgroundColor = + message?.direction == MessageDirection.WorkerToEmployer + ? theme.colors.primary + : theme.colors.tertiary; + const textAlign = + message?.direction == MessageDirection.EmployerToWorker ? 'left' : 'right'; + const alignSelf = + message?.direction == MessageDirection.EmployerToWorker + ? 'flex-start' + : 'flex-end'; + + return ( + + + {message.content} + + + ); +}; + +export default MessageBubble; diff --git a/front/src/components/messaging/MessageChannelContent.tsx b/front/src/components/messaging/MessageChannelContent.tsx new file mode 100644 index 0000000..3c68757 --- /dev/null +++ b/front/src/components/messaging/MessageChannelContent.tsx @@ -0,0 +1,51 @@ +import { FC } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import { Message } from '@/models/entities/message'; +import { MessageChannel } from '@/models/entities/messageChannel'; + +import MessageList from './MessageList'; +import MessageTextInput from './MessageTextInput'; + +/** + * The styles for the MessageChannelContent component. + */ +const styles = StyleSheet.create({ + container: { + flex: 1, + gap: 16, + }, +}); + +/** + * The props for the MessageChannelContent component. + */ +type MessageChannelContentProps = { + /** + * The message channel to display. + */ + messageChannel: MessageChannel; + + /** + * The messages of the message channel. + */ + messages: Message[]; +}; + +/** + * Displays the the conversation of a message channel. + * @constructor + */ +const MessageChannelContent: FC = ({ + messageChannel, + messages, +}) => { + return ( + + + + + ); +}; + +export default MessageChannelContent; diff --git a/front/src/components/messaging/MessageList.tsx b/front/src/components/messaging/MessageList.tsx new file mode 100644 index 0000000..a19c6d6 --- /dev/null +++ b/front/src/components/messaging/MessageList.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; +import { ScrollView, StyleSheet, View } from 'react-native'; + +import { Message } from '@/models/entities/message'; + +import MessageBubble from './MessageBubble'; + +/** + * The styles for the MessageList component. + */ +const styles = StyleSheet.create({ + container: { + gap: 30, + }, +}); + +type MessageListProps = { + /** + * The list of messages of a message channels to display. + */ + messages: Message[]; +}; + +const MessageList: FC = ({ messages }) => { + return ( + + {messages?.map((message) => ( + + + + ))} + + ); +}; + +export default MessageList; diff --git a/front/src/components/messaging/MessageTextInput.tsx b/front/src/components/messaging/MessageTextInput.tsx new file mode 100644 index 0000000..2fd86c6 --- /dev/null +++ b/front/src/components/messaging/MessageTextInput.tsx @@ -0,0 +1,62 @@ +import { FC, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { IconButton, TextInput, useTheme } from 'react-native-paper'; + +import { usePostMessageMutation } from '@/store/api/messagingApiSlice'; +import i18n from '@/utils/i18n'; + +/** + * The styles for the MessageTextInput component. + */ +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + gap: 10, + }, + textInput: { + flex: 1, + }, +}); + +/** + * The props for the MessageTextInput component. + */ +type MessageTextInputProps = { + /** + * The messages channel id. + */ + messageChannelId: string; +}; + +const MessageTextInput: FC = ({ messageChannelId }) => { + const theme = useTheme(); + // API calls + const [content, setContent] = useState(''); + const [postMessage] = usePostMessageMutation(); + + // Callbacks + const onSendPress = () => { + postMessage({ id: messageChannelId, content }); + setContent(''); + }; + return ( + + + + + ); +}; + +export default MessageTextInput; diff --git a/front/src/models/dtos/messaging/createMessageDto.ts b/front/src/models/dtos/messaging/createMessageDto.ts new file mode 100644 index 0000000..7a30835 --- /dev/null +++ b/front/src/models/dtos/messaging/createMessageDto.ts @@ -0,0 +1,14 @@ +/** + * DTO for creating a new message in a message channel. + */ +export type CreateMessageDto = { + /** + * The channel id. + */ + id: string; + + /** + * The content of the message. + */ + content: string; +}; diff --git a/front/src/models/entities/message.ts b/front/src/models/entities/message.ts new file mode 100644 index 0000000..8f5b262 --- /dev/null +++ b/front/src/models/entities/message.ts @@ -0,0 +1,42 @@ +/** + * The direction of a message. + */ +export enum MessageDirection { + WorkerToEmployer = 0, + EmployerToWorker = 1, +} + +/** + * Message channel. + */ +export type Message = { + /** + * The id of the message. + */ + id: string; + + /** + * The id of the message channel the message was sent in. + */ + channelId: string; + + /** + * The id of the employer. + */ + employerId: string; + + /** + * The direction of the message. + */ + direction: MessageDirection; + + /** + * The timestamp of the message. + */ + sentAt: string; + + /** + * The content of the message. + */ + content: string; +}; diff --git a/front/src/pages/messaging/MessageChannelListPage.tsx b/front/src/pages/messaging/MessageChannelListPage.tsx index 9a6db2b..07ecbf9 100644 --- a/front/src/pages/messaging/MessageChannelListPage.tsx +++ b/front/src/pages/messaging/MessageChannelListPage.tsx @@ -4,6 +4,7 @@ import { FC, useCallback } from 'react'; import { ScrollView, StyleSheet } from 'react-native'; import MessagingList from '@/components/messaging/MessageChannelList'; +import { MessageChannel } from '@/models/entities/messageChannel'; import { useGetMessageChannelsQuery } from '@/store/api/messagingApiSlice'; import { MessagingStackParamList } from './MessagingNav'; @@ -34,11 +35,19 @@ type MessagingListPageProps = NativeStackScreenProps< * Displays the page of message channels for the current user. * @constructor */ -const MessageChannelListPage: FC = () => { +const MessageChannelListPage: FC = ({ navigation }) => { // API calls const { data: messageChannels, refetch: refetchMessageChannels } = useGetMessageChannelsQuery(); + // Callbacks + const handleMessageChannelPress = useCallback( + (messageChannel: MessageChannel) => { + navigation.navigate('MessageChannel', { id: messageChannel.id }); + }, + [navigation], + ); + // Fetch data from the API when the page is focused useFocusEffect( useCallback(() => { @@ -55,7 +64,10 @@ const MessageChannelListPage: FC = () => { style={styles.container} contentContainerStyle={styles.contentContainer} > - + ); }; diff --git a/front/src/pages/messaging/MessageChannelPage.tsx b/front/src/pages/messaging/MessageChannelPage.tsx new file mode 100644 index 0000000..7514856 --- /dev/null +++ b/front/src/pages/messaging/MessageChannelPage.tsx @@ -0,0 +1,87 @@ +import { useFocusEffect } from '@react-navigation/native'; +import { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { FC, useCallback, useEffect } from 'react'; +import { StyleSheet, View } from 'react-native'; + +import MessageChannelContent from '@/components/messaging/MessageChannelContent'; +import { + useGetMessageChannelQuery, + useGetMessagesQuery, +} from '@/store/api/messagingApiSlice'; + +import { MessagingStackParamList } from './MessagingNav'; + +/** + * The styles for the MessageChannelPage component. + */ +const styles = StyleSheet.create({ + contentContainer: { + flex: 1, + paddingBottom: 8, + paddingHorizontal: 16, + }, +}); + +/** + * The props for the MessageChannelPage component. + */ +type MessageChannelPageProps = NativeStackScreenProps< + MessagingStackParamList, + 'MessageChannel' +>; + +/** + * The parameters for the MessageChannel route. + */ +export type MessageChannelPageParams = { + /** + * The ID of the message channel to view. + */ + id: string; +}; + +/** + * Displays the page for a single message channel. + * @constructor + */ +const MessageChannelPage: FC = ({ + route, + navigation, +}) => { + // Route params + const { id: messageChannelId } = route.params; + + // API calls + const { data: messageChannel } = useGetMessageChannelQuery(messageChannelId); + const { data: messages, refetch: refetchMessages } = + useGetMessagesQuery(messageChannelId); + + // Fetch data from the API when the page is focused + useFocusEffect( + useCallback(() => { + refetchMessages(); + }, [refetchMessages]), + ); + + // Set the header title + useEffect(() => { + navigation.setOptions({ + headerTitle: `${messageChannel?.employer.firstName} ${messageChannel?.employer.lastName}`, + }); + }, [messageChannel, navigation]); + + if (messageChannel === undefined || messages === undefined) { + return null; + } + + return ( + + + + ); +}; + +export default MessageChannelPage; diff --git a/front/src/pages/messaging/MessagingNav.tsx b/front/src/pages/messaging/MessagingNav.tsx index fd556ae..b05dc3b 100644 --- a/front/src/pages/messaging/MessagingNav.tsx +++ b/front/src/pages/messaging/MessagingNav.tsx @@ -4,12 +4,16 @@ import PaperNavigationBar from '@/components/utils/PaperNavigationBar'; import i18n from '@/utils/i18n'; import MessagingListPage from './MessageChannelListPage'; +import MessageChannelPage, { + MessageChannelPageParams, +} from './MessageChannelPage'; /** * The parameter list for the MessagingNav navigator. */ export type MessagingStackParamList = { MessageChannelList: undefined; + MessageChannel: MessageChannelPageParams; }; const MessagingStack = createNativeStackNavigator(); @@ -29,6 +33,11 @@ const MessagingNav = () => { component={MessagingListPage} options={{ headerTitle: `${i18n.t('messaging.info.messageChannel')}` }} /> + ); }; diff --git a/front/src/store/api/apiSlice.ts b/front/src/store/api/apiSlice.ts index 6a74269..0bbd5d4 100644 --- a/front/src/store/api/apiSlice.ts +++ b/front/src/store/api/apiSlice.ts @@ -21,5 +21,6 @@ export const apiSlice = createApi({ 'Job', 'JobOffer', 'MessageChannel', + 'Message', ], }); diff --git a/front/src/store/api/messagingApiSlice.ts b/front/src/store/api/messagingApiSlice.ts index b2785ea..3867760 100644 --- a/front/src/store/api/messagingApiSlice.ts +++ b/front/src/store/api/messagingApiSlice.ts @@ -1,3 +1,5 @@ +import { CreateMessageDto } from '@/models/dtos/messaging/createMessageDto'; +import { Message } from '@/models/entities/message'; import { MessageChannel } from '@/models/entities/messageChannel'; import { apiSlice } from '@/store/api/apiSlice'; @@ -19,7 +21,37 @@ export const extendedApiSlice = apiSlice.injectEndpoints({ ] : [{ type: 'MessageChannel', id: 'LIST' }], }), + getMessageChannel: builder.query({ + query: (id) => `messaging/${id}/`, + providesTags: (_result, _error, id) => [{ type: 'MessageChannel', id }], + }), + getMessages: builder.query({ + query: (id) => `messaging/${id}/messages`, + providesTags: (result) => + result + ? [ + ...result.map(({ id }) => ({ + type: 'Message' as const, + id, + })), + { type: 'Message', id: 'LIST' }, + ] + : [{ type: 'Message', id: 'LIST' }], + }), + postMessage: builder.mutation({ + query: (body) => ({ + url: `messaging/${body.id}/messages`, + method: 'POST', + body: { content: body.content }, + }), + invalidatesTags: [{ type: 'Message', id: 'LIST' }], + }), }), }); -export const { useGetMessageChannelsQuery } = extendedApiSlice; +export const { + useGetMessageChannelsQuery, + useGetMessagesQuery, + usePostMessageMutation, + useGetMessageChannelQuery, +} = extendedApiSlice; diff --git a/mock/config/db.json b/mock/config/db.json index 5439574..bf98f84 100644 --- a/mock/config/db.json +++ b/mock/config/db.json @@ -357,11 +357,80 @@ }, { "id": "c6eb0448-1600-4a22-9de4-d9473d8ed8ae", + "channelId": "1857769e-f4e9-40bd-9988-cfad399a1d3e", + "employerId": "c80f53f4-0a33-4fe3-bf7f-a755c6e2235b", + "direction": 0, + "sentAt": "2023-10-26T01:34:56Z", + "content": "Coucou Sarko, dis moi tout..." + }, + { + "id": "531e9298-f885-4659-9e32-d5bf795cf62b", + "channelId": "4f900914-f996-49a8-8909-749fdf421e5a", + "employerId": "0dc07c97-fc40-4ac2-a9f6-0b0bbbe6057e", + "direction": 1, + "sentAt": "2023-10-23T09:34:19Z", + "content": "Bonsoir Bernard, laissez moi vous présenter à un très chère ami ce week end" + }, + { + "id": "e4ab22c0-4e45-4ac9-844f-384def880b1b", "channelId": "4f900914-f996-49a8-8909-749fdf421e5a", "employerId": "0dc07c97-fc40-4ac2-a9f6-0b0bbbe6057e", "direction": 0, "sentAt": "2023-10-26T01:34:56Z", - "content": "Coucou Sarko, dis moi tout..." + "content": "Avec plaisir Monsieur Hollande, une de vos connaissances ?" + }, + { + "id": "d7f8f1e1-7e67-48f7-9f70-95ff3f0f531c", + "channelId": "4f900914-f996-49a8-8909-749fdf421e5a", + "employerId": "0dc07c97-fc40-4ac2-a9f6-0b0bbbe6057e", + "direction": 1, + "sentAt": "2023-10-28T14:45:22Z", + "content": "Oui, c'est un vieil ami de l'université. Vous allez certainement apprécier sa compagnie !" + }, + { + "id": "a41e0b53-d29d-41a4-8272-d38aaf2c0a01", + "channelId": "4f900914-f996-49a8-8909-749fdf421e5a", + "employerId": "0dc07c97-fc40-4ac2-a9f6-0b0bbbe6057e", + "direction": 0, + "sentAt": "2023-11-02T09:12:34Z", + "content": "C'est génial ! J'ai hâte de faire sa connaissance. Où et quand est-ce que nous allons nous retrouver ce week-end ?" + }, + { + "id": "7a8b6a41-71b0-4ef3-87c9-5c78ac1ae6f7", + "channelId": "4f900914-f996-49a8-8909-749fdf421e5a", + "employerId": "0dc07c97-fc40-4ac2-a9f6-0b0bbbe6057e", + "direction": 1, + "sentAt": "2023-11-04T18:20:10Z", + "content": "Parfait ! Que diriez-vous de vous retrouver samedi soir au restaurant Le Bon Vivant à 19h ?" + }, + { + "id": "1fb13e98-9321-4d22-b2e3-9a22a4617684", + "channelId": "4f900914-f996-49a8-8909-749fdf421e5a", + "employerId": "0dc07c97-fc40-4ac2-a9f6-0b0bbbe6057e", + "direction": 0, + "sentAt": "2023-11-06T12:45:55Z", + "content": "C'est une excellente idée ! Le Bon Vivant est l'un de mes endroits préférés. À samedi à 19h alors !" + }, + { + "id": "ad9dcfb8-a247-4df8-8971-3d31ce1bffa8", + "channelId": "4f900914-f996-49a8-8909-749fdf421e5a", + "employerId": "0dc07c97-fc40-4ac2-a9f6-0b0bbbe6057e", + "direction": 0, + "sentAt": "2023-11-06T12:45:55Z", + "content": "C'est une excellente idée ! Le Bon Vivant est l'un de mes endroits préférés. À samedi à 19h alors !" + }, + { + "id": "c89932bd-fea3-4d39-9df6-e2c6afcfb178", + "channelId": "4f900914-f996-49a8-8909-749fdf421e5a", + "employerId": "0dc07c97-fc40-4ac2-a9f6-0b0bbbe6057e", + "direction": 0, + "sentAt": "2023-11-06T12:45:55Z", + "content": "C'est une excellente idée ! Le Bon Vivant est l'un de mes endroits préférés. À samedi à 19h alors !" + }, + { + "content": "dasdasdasdasdasd", + "messagingId": "4f900914-f996-49a8-8909-749fdf421e5a", + "id": "lP6TCi2" } ], "employers": [ @@ -447,10 +516,10 @@ { "id": "db26513e-e9aa-42d3-9442-2fa89a82acf5", "name": "Black Cat" - }, + }, { "id": "d89170af-4d52-4366-a970-672e0ebd48b4", "name": "l'Entrecôte" } ] -} +} \ No newline at end of file diff --git a/mock/config/routes.json b/mock/config/routes.json index 25f018d..bb17343 100644 --- a/mock/config/routes.json +++ b/mock/config/routes.json @@ -13,7 +13,6 @@ "/jobOffers/:jobOfferId": "/jobOffers/:jobOfferId", "/messaging": "/messageChannels", "/messaging/:messageChannelId": "/messageChannels/:messageChannelId", - "/messaging/:messageChannelId/messages": "/messages", "/employers/:employerId": "/employers/:employerId", "/employers/:employerId/evaluations": "/employerEvaluations/:employerId", "/employers/:employerId/messaging": "/messageChannels/:employerId", diff --git a/mock/index.js b/mock/index.js index 41b7b5e..89a7c4c 100644 --- a/mock/index.js +++ b/mock/index.js @@ -1,29 +1,35 @@ -import fs from 'fs'; -import jsonServer from 'json-server'; -import fetch from 'node-fetch'; +import fs from "fs"; +import jsonServer from "json-server"; +import fetch from "node-fetch"; const SERVER_PORT = 3000; -const PROFILE_PHOTO_URL = 'https://ga.de/imgs/93/5/9/5/9/4/3/2/5/tok_7eb36f92d4b23cc7dfe601d953746972/w1512_h2177_x756_y1088_8aaef0de7b52583f.jpg'; -const CV_URL = 'https://www.overleaf.com/latex/templates/bubblecv/bcynnjktwqsx.pdf'; +const PROFILE_PHOTO_URL = "https://ga.de/imgs/93/5/9/5/9/4/3/2/5/tok_7eb36f92d4b23cc7dfe601d953746972/w1512_h2177_x756_y1088_8aaef0de7b52583f.jpg"; +const CV_URL = "https://www.overleaf.com/latex/templates/bubblecv/bcynnjktwqsx.pdf"; const server = jsonServer.create(); -const router = jsonServer.router('./config/db.json'); +const router = jsonServer.router("./config/db.json"); const middlewares = jsonServer.defaults(); const profilePhoto = await (await fetch(PROFILE_PHOTO_URL)).arrayBuffer(); const cv = await (await fetch(CV_URL)).arrayBuffer(); server - .use(middlewares) - .get('/profile/photo', (req, res) => { - res.contentType('image/jpeg').send(Buffer.from(profilePhoto)); - }) - .get('/profile/cv', (req, res) => { - res.contentType('application/pdf').send(Buffer.from(cv)); - }) - .use(jsonServer.bodyParser) - .use(jsonServer.rewriter(JSON.parse(fs.readFileSync('./config/routes.json', { encoding: 'utf8' })))) - .use(router) - .listen(SERVER_PORT, () => { - console.log(`JSON Server is listening on http://localhost:${SERVER_PORT}`); - }); + .use(middlewares) + .get("/profile/photo", (req, res) => { + res.contentType("image/jpeg").send(Buffer.from(profilePhoto)); + }) + .get("/profile/cv", (req, res) => { + res.contentType("application/pdf").send(Buffer.from(cv)); + }); +server + .get("/messaging/:channelId/messages", (req, res) => { + const { channelId } = req.params; + const messages = router.db.get("messages").filter({ channelId }).value(); + res.json(messages); + }) + .use(jsonServer.bodyParser) + .use(jsonServer.rewriter(JSON.parse(fs.readFileSync("./config/routes.json", { encoding: "utf8" })))) + .use(router) + .listen(SERVER_PORT, () => { + console.log(`JSON Server is listening on http://localhost:${SERVER_PORT}`); + });