loading...

Building Chatt - A Real-time Multi-user GraphQL Chat App

dabit3 profile image Nader Dabit Updated on ・4 min read

This repo and project is now archived

Chatt

One of the most popular use-cases of GraphQL subscriptions is building applications that enable real-time communications (i.e. messaging apps).

One of the more difficult things to do is have this real-time functionality work with multiple users & multiple channels as the data model begins to be somewhat complex & scalability issues begin to come into play when you have a large number of connected clients.

I recently built & released an open-source app, Chatt, that implements this real-time functionality with multiple users & the ability to subscribe to individual channels (chats) based on whether you are in the conversation.

When building something like this, there are two main pieces you have to get set up:

  1. User management
  2. The API

Typically, building both of these from scratch is a huge undertaking to say the least, & building them both to be scalable & secure could take months.

Thankfully today we have services like Auth0, Firebase, Okta & AppSync that allow us to spin up managed services to handle these types of workloads.

My app is using AWS AppSync for the GraphQL API & AWS Amplify to create the user management service. The app is built to work with these services but they could pretty easily be replaced with another back end or authentication provider.

To deploy this app & back end yourself, check out the instructions in the README

The Code

Let's take a quick look at some of the code. The first thing we'll look at is the base schema:

type User {
  id: ID!
  username: String!
  conversations(filter: ModelConvoLinkFilterInput, sortDirection: ModelSortDirection, limit: Int, nextToken: String): ModelConvoLinkConnection
  messages(filter: ModelMessageFilterInput, sortDirection: ModelSortDirection, limit: Int, nextToken: String): ModelMessageConnection
  createdAt: String
  updatedAt: String
}

type Conversation {
  id: ID!
  messages(filter: ModelMessageFilterInput, sortDirection: ModelSortDirection, limit: Int, nextToken: String): ModelMessageConnection
  associated(filter: ModelConvoLinkFilterInput, sortDirection: ModelSortDirection, limit: Int, nextToken: String): ModelConvoLinkConnection
  name: String!
  members: [String!]!
  createdAt: String
  updatedAt: String
}

type Message {
  id: ID!
  author: User
  authorId: String
  content: String!
  conversation: Conversation!
  messageConversationId: ID!
  createdAt: String
  updatedAt: String
}

type ConvoLink {
  id: ID!
  user: User!
  convoLinkUserId: ID
  conversation: Conversation!
  convoLinkConversationId: ID!
  createdAt: String
  updatedAt: String
}

There are three main base GraphQL types: User, Conversation, & Message. There is also a ConvoLink type that provides an association between the conversation & the user.

The operations & resolvers for these types can be viewed in more detail here.

The next thing we'll look at is the GraphQL operations that we'll be using on the client (queries, subscriptions, & mutations) because they give a good view into how the app interacts with the API.

Mutations

// This creates a new user, storing their username.
// Even though the authentication service will be handling the user management, we will also need some association with the user in the database.
const createUser = `
  mutation($username: String!) {
    createUser(input: {
      username: $username
    }) {
      id username createdAt
    }
  }
`

// This creates a new message.
// The association between the message & the conversation is made with the __messageConversationId__.
const createMessage = `mutation CreateMessage(
    $createdAt: String, $id: ID, $authorId: String, $content: String!, $messageConversationId: ID!
  ) {
  createMessage(input: {
    createdAt: $createdAt, id: $id, content: $content, messageConversationId: $messageConversationId, authorId: $authorId
  }) {
    id
    content
    authorId
    messageConversationId
    createdAt
  }
}
`;

// This creates a new conversation.
// We store the members that are involved with the conversation in the members array.
const createConvo = `mutation CreateConvo($name: String!, $members: [String!]!) {
  createConvo(input: {
    name: $name, members: $members
  }) {
    id
    name
    members
  }
}
`;

// This makes the association between the conversations & the users.
const createConvoLink = `mutation CreateConvoLink(
    $convoLinkConversationId: ID!, $convoLinkUserId: ID
  ) {
  createConvoLink(input: {
    convoLinkConversationId: $convoLinkConversationId, convoLinkUserId: $convoLinkUserId
  }) {
    id
    convoLinkUserId
    convoLinkConversationId
    conversation {
      id
      name
    }
  }
}
`;

Using these four operations, we can effectively create all of the data we will need for our app to function. After we've created the data, how to we query for it? Let's have a look.

Queries

// Fetches a single user.
const getUser = `
  query getUser($id: ID!) {
    getUser(id: $id) {
      id
      username
    }
  }
`

// Fetches a single user as well as all of their conversations
const getUserAndConversations = `
  query getUserAndConversations($id:ID!) {
    getUser(id:$id) {
      id
      username
      conversations(limit: 100) {
        items {
          id
          conversation {
            id
            name
          }
        }
      }
    }
  }
`

// gets a single conversation based on ID
const getConvo = `
  query getConvo($id: ID!) {
    getConvo(id:$id) {
      id
      name
      members
      messages(limit: 100) {
        items {
          id
          content
          authorId
          messageConversationId
          createdAt
        }
      }
      createdAt
      updatedAt
    }
  }
`

// lists all of the users in the app
const listUsers = `
  query listUsers {
    listUsers {
      items {
        id
        username
        createdAt
      }
    }
  }
`

For the real time piece, we have 2 subscriptions.

Subscriptions

// When a new message is created, send an update to the client with the id, content, authorId, createdAt & messageConversationId fields
const onCreateMessage = `
  subscription onCreateMessage($messageConversationId: ID!) {
    onCreateMessage(messageConversationId: $messageConversationId) {
      id
      content
      authorId
      messageConversationId
      createdAt
    }
  }
`

// When a new user is created, send an update to the client with the id, username, & createdAt fields
const onCreateUser = `subscription OnCreateUser {
  onCreateUser {
    id
    username
    createdAt
  }
}
`;

State management

There isn't much actual state management that goes on outside of the Apollo / AppSync SDK. The only thing I have implemented outside of that is a way to access the user data in a synchronous way by storing it in MobX. In the future, I'd like to replace this with Context or possibly even merging in with Apollo as well.

Offline

As far as offline functionality is concerned, since we're using the AWS AppSync JS SDK for most of it, there is nothing else we have to do other than provide the right optimistic updates.

The AppSync JS SDK leverages the existing Apollo cache to handle offline scenarios & queue up any operations that happen offline. When the user comes back online, the updates are sent to the server in the order in which they were created.

Conclusion

I learned a lot about working with subscriptions when building this app, & will be adding additional functionality like the aforementioned state management being completely handled by the AppSync SDK among other things.

To learn more about this philosophy of leveraging managed services & APIs to build robust applications, check out my post Full-Stack Development in the Era of Serverless Computing.

My Name is Nader Dabit. I am a Developer Advocate at AWS Mobile working with projects like AWS AppSync and AWS Amplify. I also specialize in cross-platform application development.

Discussion

pic
Editor guide
Collapse
akhh profile image
akhh

Thanks for the post! I have one question with the subscriptions in Amplify/Appsync. Will the subscription automatically reassigned if there is an network issue? Or do you have to do it manually, e.g. listen for navigator.onLine for example?

Collapse
namnv11 profile image
Nam NV

Thank for sharing.
In my case, I have a 1000 conversation in chat history and each conversation have multiple user
So If I using appsync as your post, I need to make 1000 subscription for each user (it's bad if I doing like that)
As I known that appsync is not support to subscription with array ID (onCreateMessage($messageConversationId: ID!))

So my solution for this case:

  1. writing lambda function with name actionCreateMessage for mutation createMessage
  2. Add type PubMessage {userId, message} with subscription and create mutation
  3. actionCreateMessage will do:
  4. getting list user of conversation
  5. call createPubMessage for each user
  6. in client using only one subscription onPubMessage(userId: ID!)

@dabit3 Could you help to advice me ? Is there any better solution for that used appsync ?

Collapse
liguoma profile image
Liguo-Ma

Hello Thanks for your post!
I have one question.
For one conversation I can not subscribe only 10 message.
After 10 messages I cannot subscribe onMessageCreate event:(
Please let me know what I should fix
Thanks

Collapse
ravnarayan profile image
ravnarayan

Hey @ Nader,

There are a couple errors, can you advise?

  • Attempted import error: 'compose' is not exported from 'react-apollo' -./src/index.js Module not found: Can't resolve 'aws-appsync-react' in 'C:\Users\my\my-project\aws-appsync-chat\src'

do you have a fix?

thank you,

Collapse
curlythemes profile image
Curly Themes

have you tested how aws appsync subscriptions can be used with vuex?

Collapse
dance2die profile image
Sung M. Kim

Thanks for the post & the open source app, Nader 👍

Would you also add "opensource" tag by chance?
as it could help people following the tag 🙂

Collapse
dabit3 profile image
Nader Dabit Author

Hey Sung, great idea. Updating now :).

Collapse
alsmith808 profile image
Alan Smith

Would it need a lot of tweekeing to work with DataStore, for my usecase I dont need realtime functionality, the conversation functionality is all I really need, thanks anyone.

Collapse
ronifintech profile image
Roni Yosofov

How do you generate messageConversationId? it seems to be a required field... what's the right way to generate this random id?