use React Query to fetch messages

Change-Id: I85d0700efcc947462943ed0379498902b73950b3
diff --git a/client/src/components/ConversationView.js b/client/src/components/ConversationView.js
index dfba0d1..964d5bf 100644
--- a/client/src/components/ConversationView.js
+++ b/client/src/components/ConversationView.js
@@ -7,35 +7,40 @@
 import io from "socket.io-client";
 import { Box, Stack, Typography } from '@mui/material';
 import ConversationAvatar from './ConversationAvatar';
+import { useConversationQuery, useMessagesQuery } from '../services/conversation';
 
 const ConversationView = props => {
   const [loadingMessages, setLoadingMessages] = useState(false)
-  const [socket, setSocket] = useState(undefined)
-  const [state, setState] = useState({
-    loaded: false,
-    error: false,
-    conversation: undefined
-  })
+  const [socket, setSocket] = useState()
+  const [conversation, setConversation] = useState()
+  const [messages, setMessages] = useState([])
+  const [loaded, setLoaded] = useState(true)
+  const [error, setError] = useState(false)
+
+  const conversationQuery = useConversationQuery(props.accountId, props.conversationId)
+  const messagesQuery = useMessagesQuery(props.accountId, props.conversationId)
 
   useEffect(() => {
-    const controller = new AbortController()
-    authManager.fetch(`/api/accounts/${props.accountId}/conversations/${props.conversationId}`, {signal: controller.signal})
-    .then(res => res.json())
-    .then(result => {
-      console.log(result)
-      setState({
-        loaded: true,
-        conversation: Conversation.from(props.accountId, result)
-      })
-    }, error => {
-      console.log(`get error ${error}`)
-      setState({
-        loaded: true,
-        error: true
-      })
-    })
-   // return () => controller.abort() // crash on React18
-  }, [props.accountId, props.conversationId])
+    if (conversationQuery.data) {
+      const conversation = Conversation.from(props.accountId, conversationQuery.data)
+      setConversation(conversation)
+    }
+  }, [conversationQuery.data])
+
+  useEffect(() => {
+    if (messagesQuery.data) {
+      const sortedMessages = sortMessages(messagesQuery.data)
+      setMessages(sortedMessages)
+    }
+  }, [messagesQuery.data])
+
+  useEffect(() => {
+    setLoaded(!(conversationQuery.isLoading || messagesQuery.isLoading))
+  }, [conversationQuery.isLoading, messagesQuery.isLoading])
+  
+  useEffect(() => {
+    setError(conversationQuery.isError || messagesQuery.isError)
+  }, [conversationQuery.isError, messagesQuery.isError])
 
   useEffect(() => {
     console.log("io.connect")
@@ -49,41 +54,18 @@
   }, [])
 
   useEffect(() => {
-    if (!state.conversation)
+    if (!conversation)
       return
-    console.log(`io set conversation ${state.conversation.getId()} `+ socket)
+    console.log(`io set conversation ${props.conversationId} `+ socket)
     if (socket)
-      socket.emit('conversation', { accountId: state.conversation.getAccountId(), conversationId: state.conversation.getId() })
+      socket.emit('conversation', { accountId: props.accountId, conversationId: props.conversationId })
     socket.off('newMessage')
     socket.on('newMessage', (data) => {
       console.log("newMessage")
       console.log(data)
-      setState(state => {
-        if (state.conversation)
-          state.conversation.addMessage(data)
-        return {...state}
-      })
+      setMessages(addMessage(messages, data))
     })
-  }, [state.conversation ? state.conversation.getId() : "", socket])
-
-  useEffect(() => {
-    if (!loadingMessages || !state.conversation)
-      return
-    console.log(`Load more messages`)
-    const controller = new AbortController()
-    authManager.fetch(`/api/accounts/${state.conversation.getAccountId()}/conversations/${state.conversation.getId()}/messages`, {signal: controller.signal})
-      .then(res => res.json())
-      .then(messages => {
-        console.log(messages)
-        setLoadingMessages(false)
-        setState(state => {
-          if (state.conversation)
-            state.conversation.addLoadedMessages(messages)
-            return {...state}
-          })
-      }).catch(e => console.log(e))
-     // return () => controller.abort() // crash on React18
-  }, [state, loadingMessages])
+  }, [conversation ? props.conversationId : "", socket])
 
   const sendMessage = (message) => {
     authManager.fetch(`/api/accounts/${props.accountId}/conversations/${props.conversationId}`, {
@@ -96,10 +78,10 @@
     })
   }
 
-  if (state.loaded === false) {
+  if (!loaded) {
       return <LoadingPage />
-  } else if (state.error === true) {
-      return <div>Error loding {props.conversationId}</div>
+  } else if (error) {
+      return <div>Error loading {props.conversationId}</div>
   }
 
   return (
@@ -109,19 +91,19 @@
     >
       <Stack direction="row" flexGrow={0}>
         <Box style={{ margin: 16, flexShrink: 0 }}>
-          <ConversationAvatar displayName={state.conversation.getDisplayNameNoFallback()} />
+          <ConversationAvatar displayName={conversation?.getDisplayNameNoFallback()} />
         </Box>
         <Box style={{ flex: "1 1 auto", overflow: 'hidden' }}>
-          <Typography className="title" variant="h6">{state.conversation.getDisplayName()}</Typography>
-          <Typography className="subtitle" variant="subtitle1" >{state.conversation.getId()}</Typography>
+          <Typography className="title" variant="h6">{conversation?.getDisplayName()}</Typography>
+          <Typography className="subtitle" variant="subtitle1" >{props.conversationId}</Typography>
         </Box>
       </Stack>
       <Stack flexGrow={1} overflow="auto" direction="column-reverse">
         <MessageList
-          conversationId={state.conversation.getId()}
+          conversationId={props.conversationId}
           loading={loadingMessages} 
           loadMore={() => setLoadingMessages(true)}
-          messages={state.conversation.getMessages()}
+          messages={messages}
         />
       </Stack>
       <Stack flexGrow={0}>
@@ -131,4 +113,22 @@
   )
 }
 
+const addMessage = (sortedMessages, message) => {
+  if (sortedMessages.length === 0) {
+    return [message]
+  } else if (message.id === sortedMessages[sortedMessages.length - 1].linearizedParent) {
+    return [...sortedMessages, message]
+  } else if (message.linearizedParent === sortedMessages[0].id) {
+    return [message, ...sortedMessages]
+  } else {
+    console.log("Can't insert message " + message.id)
+  }
+}
+
+const sortMessages = (messages) => {
+  let sortedMessages = []
+  messages.forEach(message => sortedMessages = addMessage(sortedMessages, message))
+  return sortedMessages
+}
+
 export default ConversationView
\ No newline at end of file
diff --git a/client/src/components/MessageList.js b/client/src/components/MessageList.js
index 3f16e46..f6da60c 100644
--- a/client/src/components/MessageList.js
+++ b/client/src/components/MessageList.js
@@ -1,5 +1,5 @@
 import dayjs from "dayjs"
-import React, { useEffect } from 'react'
+import React, { useEffect, useMemo } from 'react'
 import dayOfYear from 'dayjs/plugin/dayOfYear'
 import isBetween from 'dayjs/plugin/isBetween'
 import { Stack } from "@mui/system"
@@ -9,7 +9,10 @@
 dayjs.extend(isBetween)
 
 export default function MessageList(props) {
-  const messagesComponents = buildMessagesList(props.messages)
+  const messagesComponents = useMemo(
+    () => buildMessagesList(props.messages),
+    [props.messages]
+  )
 
   useEffect(() => {
     if (!props.loading)
diff --git a/client/src/index.js b/client/src/index.js
index ffa6443..cdd90a4 100644
--- a/client/src/index.js
+++ b/client/src/index.js
@@ -11,15 +11,26 @@
 // import config from "../sentry-client.config.json"
 import * as Sentry from "@sentry/react";
 import { BrowserTracing } from "@sentry/tracing";
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+const queryClient = new QueryClient({
+  defaultOptions: {
+    queries: {
+      cacheTime: Infinity, // websocket is responsible to tell when data needs to be updated
+    },
+  },
+})
 
 const container = document.getElementById("root");
 const root = createRoot(container);
 root.render(
   <Provider store={store}>
     <React.StrictMode>
-      <Router>
-        <App/>
-      </Router>
+      <QueryClientProvider client={queryClient}>
+        <Router>
+          <App/>
+        </Router>
+      </QueryClientProvider>
     </React.StrictMode>
   </Provider>
 );
diff --git a/client/src/services/conversation.js b/client/src/services/conversation.js
new file mode 100644
index 0000000..f6b0f49
--- /dev/null
+++ b/client/src/services/conversation.js
@@ -0,0 +1,34 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import axios from "axios"
+
+export const useConversationQuery = (accountId, conversationId) => {
+    return useQuery(
+        ["conversation", accountId, conversationId],
+        () => fetchConversation(accountId, conversationId),
+        {
+            "enabled": !!(accountId && conversationId),
+        }
+    )
+}
+
+export const useMessagesQuery = (accountId, conversationId) => {
+    return useQuery(
+        ["messages", accountId, conversationId],
+        () => fetchMessages(accountId, conversationId),
+        {
+            "enabled": !!(accountId && conversationId),
+        }
+    )
+}
+
+const fetchConversation = (accountId, conversationId) => (
+    axios
+    .get(`/api/accounts/${accountId}/conversations/${conversationId}`)
+    .then((result) => result.data)
+)
+
+const fetchMessages = (accountId, conversationId) => (
+    axios
+    .get(`/api/accounts/${accountId}/conversations/${conversationId}/messages`)
+    .then((result) => result.data)
+)
\ No newline at end of file