Skip to content

Latest commit

 

History

History
2051 lines (1927 loc) · 77.8 KB

step4.md

File metadata and controls

2051 lines (1927 loc) · 77.8 KB

Step 4: GraphQL Mutations

This is the fourth blog in a multipart series where we will be building Chatty, a WhatsApp clone, using React Native and Apollo.

Here’s what we will accomplish in this tutorial:

  1. Design GraphQL Mutations and add them to the GraphQL Schemas on our server
  2. Modify the layout on our React Native client to let users send Messages
  3. Build GraphQL Mutations on our RN client and connect them to components using react-apollo
  4. Add Optimistic UI to our GraphQL Mutations so our RN client updates as soon as the Message is sent — even before the server sends a response!

YOUR CHALLENGE

  1. Add GraphQL Mutations on our server for creating, modifying, and deleting Groups
  2. Add new Screens to our React Native app for creating, modifying, and deleting Groups
  3. Build GraphQL Queries and Mutations for our new Screens and connect them using react-apollo

Adding GraphQL Mutations on the Server

While GraphQL Queries let us fetch data from our server, GraphQL Mutations allow us to modify our server held data.

To add a mutation to our GraphQL endpoint, we start by defining the mutation in our GraphQL Schema much like we did with queries. We’ll define a createMessage mutation that will enable users to send a new message to a Group:

type Mutation {
  # create a new message
  # text is the message text
  # userId is the id of the user sending the message
  # groupId is the id of the group receiving the message
  createMessage(text: String!, userId: Int!, groupId: Int!): Message
}

GraphQL Mutations are written nearly identically like GraphQL Queries. For now, we will require a userId parameter to identify who is creating the Message, but we won’t need this field once we implement authentication in a future tutorial.

Let’s update our Schema in server/data/schema.js to include the mutation:

Changed server/data/schema.js
@@ -44,8 +44,16 @@
 ┊44┊44┊    group(id: Int!): Group
 ┊45┊45┊  }
 ┊46┊46┊
+┊  ┊47┊  type Mutation {
+┊  ┊48┊    # send a message to a group
+┊  ┊49┊    createMessage(
+┊  ┊50┊      text: String!, userId: Int!, groupId: Int!
+┊  ┊51┊    ): Message
+┊  ┊52┊  }
+┊  ┊53┊  
 ┊47┊54┊  schema {
 ┊48┊55┊    query: Query
+┊  ┊56┊    mutation: Mutation
 ┊49┊57┊  }
 ┊50┊58┊`;

We also need to modify our resolvers to handle our new mutation. We’ll modify server/data/resolvers.js as follows:

Changed server/data/resolvers.js
@@ -18,6 +18,15 @@
 ┊18┊18┊      return User.findOne({ where: args });
 ┊19┊19┊    },
 ┊20┊20┊  },
+┊  ┊21┊  Mutation: {
+┊  ┊22┊    createMessage(_, { text, userId, groupId }) {
+┊  ┊23┊      return Message.create({
+┊  ┊24┊        userId,
+┊  ┊25┊        text,
+┊  ┊26┊        groupId,
+┊  ┊27┊      });
+┊  ┊28┊    },
+┊  ┊29┊  },
 ┊21┊30┊  Group: {
 ┊22┊31┊    users(group) {
 ┊23┊32┊      return group.getUsers();

That’s it! When a client uses createMessage, the resolver will use the Message model passed by our connector and call Message.create with arguments from the mutation. The Message.create function returns a Promise that will resolve with the newly created Message.

We can easily test our newly minted createMessage mutation in GraphQL Playground to make sure everything works: Create Message Img

Designing the Input

Wow, that was way faster than when we added queries! All the heavy lifting we did in the first 3 parts of this series is starting to pay off….

Now that our server allows clients to create messages, we can build that functionality into our React Native client. First, we’ll start by creating a new component MessageInput where our users will be able to input their messages.

For this component, let's use cool icons. react-native-vector-icons is the goto package for adding icons to React Native. Please follow the instructions in the react-native-vector-icons README before moving onto the next step.

# make sure you're adding this package in the client folder!!!
cd client

npm i react-native-vector-icons
react-native link
# this is not enough to install icons!!! PLEASE FOLLOW THE INSTRUCTIONS IN THE README TO PROPERLY INSTALL ICONS!

After completing the steps in the README to install icons, we can start putting together the MessageInput component in a new file client/src/components/message-input.component.js:

Added client/src/components/message-input.component.js
@@ -0,0 +1,95 @@
+┊  ┊ 1┊import React, { Component } from 'react';
+┊  ┊ 2┊import PropTypes from 'prop-types';
+┊  ┊ 3┊import {
+┊  ┊ 4┊  StyleSheet,
+┊  ┊ 5┊  TextInput,
+┊  ┊ 6┊  View,
+┊  ┊ 7┊} from 'react-native';
+┊  ┊ 8┊
+┊  ┊ 9┊import Icon from 'react-native-vector-icons/FontAwesome';
+┊  ┊10┊
+┊  ┊11┊const styles = StyleSheet.create({
+┊  ┊12┊  container: {
+┊  ┊13┊    alignSelf: 'flex-end',
+┊  ┊14┊    backgroundColor: '#f5f1ee',
+┊  ┊15┊    borderColor: '#dbdbdb',
+┊  ┊16┊    borderTopWidth: 1,
+┊  ┊17┊    flexDirection: 'row',
+┊  ┊18┊  },
+┊  ┊19┊  inputContainer: {
+┊  ┊20┊    flex: 1,
+┊  ┊21┊    paddingHorizontal: 12,
+┊  ┊22┊    paddingVertical: 6,
+┊  ┊23┊  },
+┊  ┊24┊  input: {
+┊  ┊25┊    backgroundColor: 'white',
+┊  ┊26┊    borderColor: '#dbdbdb',
+┊  ┊27┊    borderRadius: 15,
+┊  ┊28┊    borderWidth: 1,
+┊  ┊29┊    color: 'black',
+┊  ┊30┊    height: 32,
+┊  ┊31┊    paddingHorizontal: 8,
+┊  ┊32┊  },
+┊  ┊33┊  sendButtonContainer: {
+┊  ┊34┊    paddingRight: 12,
+┊  ┊35┊    paddingVertical: 6,
+┊  ┊36┊  },
+┊  ┊37┊  sendButton: {
+┊  ┊38┊    height: 32,
+┊  ┊39┊    width: 32,
+┊  ┊40┊  },
+┊  ┊41┊  iconStyle: {
+┊  ┊42┊    marginRight: 0, // default is 12
+┊  ┊43┊  },
+┊  ┊44┊});
+┊  ┊45┊
+┊  ┊46┊const sendButton = send => (
+┊  ┊47┊  <Icon.Button
+┊  ┊48┊    backgroundColor={'blue'}
+┊  ┊49┊    borderRadius={16}
+┊  ┊50┊    color={'white'}
+┊  ┊51┊    iconStyle={styles.iconStyle}
+┊  ┊52┊    name="send"
+┊  ┊53┊    onPress={send}
+┊  ┊54┊    size={16}
+┊  ┊55┊    style={styles.sendButton}
+┊  ┊56┊  />
+┊  ┊57┊);
+┊  ┊58┊
+┊  ┊59┊class MessageInput extends Component {
+┊  ┊60┊  constructor(props) {
+┊  ┊61┊    super(props);
+┊  ┊62┊    this.state = {};
+┊  ┊63┊    this.send = this.send.bind(this);
+┊  ┊64┊  }
+┊  ┊65┊
+┊  ┊66┊  send() {
+┊  ┊67┊    this.props.send(this.state.text);
+┊  ┊68┊    this.textInput.clear();
+┊  ┊69┊    this.textInput.blur();
+┊  ┊70┊  }
+┊  ┊71┊
+┊  ┊72┊  render() {
+┊  ┊73┊    return (
+┊  ┊74┊      <View style={styles.container}>
+┊  ┊75┊        <View style={styles.inputContainer}>
+┊  ┊76┊          <TextInput
+┊  ┊77┊            ref={(ref) => { this.textInput = ref; }}
+┊  ┊78┊            onChangeText={text => this.setState({ text })}
+┊  ┊79┊            style={styles.input}
+┊  ┊80┊            placeholder="Type your message here!"
+┊  ┊81┊          />
+┊  ┊82┊        </View>
+┊  ┊83┊        <View style={styles.sendButtonContainer}>
+┊  ┊84┊          {sendButton(this.send)}
+┊  ┊85┊        </View>
+┊  ┊86┊      </View>
+┊  ┊87┊    );
+┊  ┊88┊  }
+┊  ┊89┊}
+┊  ┊90┊
+┊  ┊91┊MessageInput.propTypes = {
+┊  ┊92┊  send: PropTypes.func.isRequired,
+┊  ┊93┊};
+┊  ┊94┊
+┊  ┊95┊export default MessageInput;

Our MessageInput component is a View that wraps a controlled TextInput and an Icon.Button. When the button is pressed, props.send will be called with the current state of the TextInput text and then the TextInput will clear. We’ve also added some styling to keep everything looking snazzy.

Let’s add MessageInput to the bottom of the Messages screen and create a placeholder send function:

Changed client/src/screens/messages.screen.js
@@ -10,6 +10,7 @@
 ┊10┊10┊import { graphql, compose } from 'react-apollo';
 ┊11┊11┊
 ┊12┊12┊import Message from '../components/message.component';
+┊  ┊13┊import MessageInput from '../components/message-input.component';
 ┊13┊14┊import GROUP_QUERY from '../graphql/group.query';
 ┊14┊15┊
 ┊15┊16┊const styles = StyleSheet.create({
@@ -46,6 +47,7 @@
 ┊46┊47┊    };
 ┊47┊48┊
 ┊48┊49┊    this.renderItem = this.renderItem.bind(this);
+┊  ┊50┊    this.send = this.send.bind(this);
 ┊49┊51┊  }
 ┊50┊52┊
 ┊51┊53┊  componentWillReceiveProps(nextProps) {
@@ -65,6 +67,11 @@
 ┊65┊67┊    }
 ┊66┊68┊  }
 ┊67┊69┊
+┊  ┊70┊  send(text) {
+┊  ┊71┊    // TODO: send the message
+┊  ┊72┊    console.log(`sending message: ${text}`);
+┊  ┊73┊  }
+┊  ┊74┊
 ┊68┊75┊  keyExtractor = item => item.id.toString();
 ┊69┊76┊
 ┊70┊77┊  renderItem = ({ item: message }) => (
@@ -96,6 +103,7 @@
 ┊ 96┊103┊          renderItem={this.renderItem}
 ┊ 97┊104┊          ListEmptyComponent={<View />}
 ┊ 98┊105┊        />
+┊   ┊106┊        <MessageInput send={this.send} />
 ┊ 99┊107┊      </View>
 ┊100┊108┊    );
 ┊101┊109┊  }

It should look like this: Message Input Image

But don’t be fooled by your simulator! This UI will break on a phone because of the keyboard: Broken Input Image

You are not the first person to groan over this issue. For you and the many groaners out there, the wonderful devs at Facebook have your back. KeyboardAvoidingView to the rescue!

Changed client/src/screens/messages.screen.js
@@ -1,6 +1,7 @@
 ┊1┊1┊import {
 ┊2┊2┊  ActivityIndicator,
 ┊3┊3┊  FlatList,
+┊ ┊4┊  KeyboardAvoidingView,
 ┊4┊5┊  StyleSheet,
 ┊5┊6┊  View,
 ┊6┊7┊} from 'react-native';
@@ -96,7 +97,12 @@
 ┊ 96┊ 97┊
 ┊ 97┊ 98┊    // render list of messages for group
 ┊ 98┊ 99┊    return (
-┊ 99┊   ┊      <View style={styles.container}>
+┊   ┊100┊      <KeyboardAvoidingView
+┊   ┊101┊        behavior={'position'}
+┊   ┊102┊        contentContainerStyle={styles.container}
+┊   ┊103┊        keyboardVerticalOffset={64}
+┊   ┊104┊        style={styles.container}
+┊   ┊105┊      >
 ┊100┊106┊        <FlatList
 ┊101┊107┊          data={group.messages.slice().reverse()}
 ┊102┊108┊          keyExtractor={this.keyExtractor}
@@ -104,7 +110,7 @@
 ┊104┊110┊          ListEmptyComponent={<View />}
 ┊105┊111┊        />
 ┊106┊112┊        <MessageInput send={this.send} />
-┊107┊   ┊      </View>
+┊   ┊113┊      </KeyboardAvoidingView>
 ┊108┊114┊    );
 ┊109┊115┊  }
 ┊110┊116┊}

Fixed Input Image

Our layout looks ready. Now let’s make it work!

Adding GraphQL Mutations on the Client

Let’s start by defining our GraphQL Mutation like we would using GraphQL Playground:

mutation createMessage($text: String!, $userId: Int!, $groupId: Int!) {
  createMessage(text: $text, userId: $userId, groupId: $groupId) {
    id
    from {
      id
      username
    }
    createdAt
    text
  }
}

That looks fine, but notice the Message fields we want to see returned look exactly like the Message fields we are using for GROUP_QUERY:

query group($groupId: Int!) {
  group(id: $groupId) {
    id
    name
    users {
      id
      username
    }
    messages {
      id
      from {
        id
        username
      }
      createdAt
      text
    }
  }
}

GraphQL allows us to reuse pieces of queries and mutations with Fragments. We can factor out this common set of fields into a MessageFragment that looks like this:

Added client/src/graphql/message.fragment.js
@@ -0,0 +1,18 @@
+┊  ┊ 1┊import gql from 'graphql-tag';
+┊  ┊ 2┊
+┊  ┊ 3┊const MESSAGE_FRAGMENT = gql`
+┊  ┊ 4┊  fragment MessageFragment on Message {
+┊  ┊ 5┊    id
+┊  ┊ 6┊    to {
+┊  ┊ 7┊      id
+┊  ┊ 8┊    }
+┊  ┊ 9┊    from {
+┊  ┊10┊      id
+┊  ┊11┊      username
+┊  ┊12┊    }
+┊  ┊13┊    createdAt
+┊  ┊14┊    text
+┊  ┊15┊  }
+┊  ┊16┊`;
+┊  ┊17┊
+┊  ┊18┊export default MESSAGE_FRAGMENT;

Now we can apply MESSAGE_FRAGMENT to GROUP_QUERY by changing our code as follows:

Changed client/src/graphql/group.query.js
@@ -1,5 +1,7 @@
 ┊1┊1┊import gql from 'graphql-tag';
 ┊2┊2┊
+┊ ┊3┊import MESSAGE_FRAGMENT from './message.fragment';
+┊ ┊4┊
 ┊3┊5┊const GROUP_QUERY = gql`
 ┊4┊6┊  query group($groupId: Int!) {
 ┊5┊7┊    group(id: $groupId) {
@@ -10,16 +12,11 @@
 ┊10┊12┊        username
 ┊11┊13┊      }
 ┊12┊14┊      messages {
-┊13┊  ┊        id
-┊14┊  ┊        from {
-┊15┊  ┊          id
-┊16┊  ┊          username
-┊17┊  ┊        }
-┊18┊  ┊        createdAt
-┊19┊  ┊        text
+┊  ┊15┊        ... MessageFragment
 ┊20┊16┊      }
 ┊21┊17┊    }
 ┊22┊18┊  }
+┊  ┊19┊  ${MESSAGE_FRAGMENT}
 ┊23┊20┊`;
 ┊24┊21┊
 ┊25┊22┊export default GROUP_QUERY;

Let’s also write our createMessage mutation using messageFragment in a new file client/src/graphql/create-message.mutation.js:

Added client/src/graphql/create-message.mutation.js
@@ -0,0 +1,14 @@
+┊  ┊ 1┊import gql from 'graphql-tag';
+┊  ┊ 2┊
+┊  ┊ 3┊import MESSAGE_FRAGMENT from './message.fragment';
+┊  ┊ 4┊
+┊  ┊ 5┊const CREATE_MESSAGE_MUTATION = gql`
+┊  ┊ 6┊  mutation createMessage($text: String!, $userId: Int!, $groupId: Int!) {
+┊  ┊ 7┊    createMessage(text: $text, userId: $userId, groupId: $groupId) {
+┊  ┊ 8┊      ... MessageFragment
+┊  ┊ 9┊    }
+┊  ┊10┊  }
+┊  ┊11┊  ${MESSAGE_FRAGMENT}
+┊  ┊12┊`;
+┊  ┊13┊
+┊  ┊14┊export default CREATE_MESSAGE_MUTATION;

Now all we have to do is plug our mutation into our Messages component using the graphql module from react-apollo. Before we connect everything, let’s see what a mutation call with the graphql module looks like:

const createMessage = graphql(CREATE_MESSAGE_MUTATION, {
  props: ({ ownProps, mutate }) => ({
    createMessage: ({ text, userId, groupId }) =>
      mutate({
        variables: { text, userId, groupId },
      }),
  }),
});

Just like with a GraphQL Query, we first pass our mutation to graphql, followed by an Object with configuration params. The props param accepts a function with named arguments including ownProps (the components current props) and mutate. This function should return an Object with the name of the function that we plan to call inside our component, which executes mutate with the variables we wish to pass. If that sounds complicated, it’s because it is. Kudos to the Meteor team for putting it together though, because it’s actually some very clever code.

At the end of the day, once you write your first mutation, it’s really mostly a matter of copy/paste and changing the names of the variables.

Okay, so let’s put it all together in messages.screen.js:

Changed client/src/screens/messages.screen.js
@@ -13,6 +13,7 @@
 ┊13┊13┊import Message from '../components/message.component';
 ┊14┊14┊import MessageInput from '../components/message-input.component';
 ┊15┊15┊import GROUP_QUERY from '../graphql/group.query';
+┊  ┊16┊import CREATE_MESSAGE_MUTATION from '../graphql/create-message.mutation';
 ┊16┊17┊
 ┊17┊18┊const styles = StyleSheet.create({
 ┊18┊19┊  container: {
@@ -69,8 +70,11 @@
 ┊69┊70┊  }
 ┊70┊71┊
 ┊71┊72┊  send(text) {
-┊72┊  ┊    // TODO: send the message
-┊73┊  ┊    console.log(`sending message: ${text}`);
+┊  ┊73┊    this.props.createMessage({
+┊  ┊74┊      groupId: this.props.navigation.state.params.groupId,
+┊  ┊75┊      userId: 1, // faking the user for now
+┊  ┊76┊      text,
+┊  ┊77┊    });
 ┊74┊78┊  }
 ┊75┊79┊
 ┊76┊80┊  keyExtractor = item => item.id.toString();
@@ -116,6 +120,14 @@
 ┊116┊120┊}
 ┊117┊121┊
 ┊118┊122┊Messages.propTypes = {
+┊   ┊123┊  createMessage: PropTypes.func,
+┊   ┊124┊  navigation: PropTypes.shape({
+┊   ┊125┊    state: PropTypes.shape({
+┊   ┊126┊      params: PropTypes.shape({
+┊   ┊127┊        groupId: PropTypes.number,
+┊   ┊128┊      }),
+┊   ┊129┊    }),
+┊   ┊130┊  }),
 ┊119┊131┊  group: PropTypes.shape({
 ┊120┊132┊    messages: PropTypes.array,
 ┊121┊133┊    users: PropTypes.array,
@@ -134,6 +146,16 @@
 ┊134┊146┊  }),
 ┊135┊147┊});
 ┊136┊148┊
+┊   ┊149┊const createMessageMutation = graphql(CREATE_MESSAGE_MUTATION, {
+┊   ┊150┊  props: ({ mutate }) => ({
+┊   ┊151┊    createMessage: ({ text, userId, groupId }) =>
+┊   ┊152┊      mutate({
+┊   ┊153┊        variables: { text, userId, groupId },
+┊   ┊154┊      }),
+┊   ┊155┊  }),
+┊   ┊156┊});
+┊   ┊157┊
 ┊137┊158┊export default compose(
 ┊138┊159┊  groupQuery,
+┊   ┊160┊  createMessageMutation,
 ┊139┊161┊)(Messages);

By attaching createMessage with compose, we attach a createMessage function to the component’s props. We call props.createMessage in send with the required variables (we’ll keep faking the user for now). When the user presses the send button, this method will get called and the mutation should execute.

Let’s run the app and see what happens: Send Fail Gif

What went wrong? Well technically nothing went wrong. Our mutation successfully executed, but we’re not seeing our message pop up. Why? Running a mutation doesn’t automatically update our queries with new data! If we were to refresh the page, we’d actually see our message. This issue only arrises when we are adding or removing data with our mutation.

To overcome this challenge, react-apollo lets us declare a property update within the argument we pass to mutate. In update, we specify which queries should update after the mutation executes and how the data will transform.

Our modified createMessage should look like this:

Changed client/src/screens/messages.screen.js
@@ -151,7 +151,29 @@
 ┊151┊151┊    createMessage: ({ text, userId, groupId }) =>
 ┊152┊152┊      mutate({
 ┊153┊153┊        variables: { text, userId, groupId },
+┊   ┊154┊        update: (store, { data: { createMessage } }) => {
+┊   ┊155┊          // Read the data from our cache for this query.
+┊   ┊156┊          const groupData = store.readQuery({
+┊   ┊157┊            query: GROUP_QUERY,
+┊   ┊158┊            variables: {
+┊   ┊159┊              groupId,
+┊   ┊160┊            },
+┊   ┊161┊          });
+┊   ┊162┊
+┊   ┊163┊          // Add our message from the mutation to the end.
+┊   ┊164┊          groupData.group.messages.unshift(createMessage);
+┊   ┊165┊
+┊   ┊166┊          // Write our data back to the cache.
+┊   ┊167┊          store.writeQuery({
+┊   ┊168┊            query: GROUP_QUERY,
+┊   ┊169┊            variables: {
+┊   ┊170┊              groupId,
+┊   ┊171┊            },
+┊   ┊172┊            data: groupData,
+┊   ┊173┊          });
+┊   ┊174┊        },
 ┊154┊175┊      }),
+┊   ┊176┊
 ┊155┊177┊  }),
 ┊156┊178┊});

In update, we first retrieve the existing data for the query we want to update (GROUP_QUERY) along with the specific variables we passed to that query. This data comes to us from our Redux store of Apollo data. We check to see if the new Message returned from createMessage already exists (in case of race conditions down the line), and then update the previous query result by sticking the new message in front. We then use this modified data object and rewrite the results to the Apollo store with store.writeQuery, being sure to pass all the variables associated with our query. This will force props to change reference and the component to rerender. Fixed Send Gif

Optimistic UI

But wait! There’s more!

update will currently only update the query after the mutation succeeds and a response is sent back on the server. But we don’t want to wait till the server returns data  —  we crave instant gratification! If a user with shoddy internet tried to send a message and it didn’t show up right away, they’d probably try and send the message again and again and end up sending the message multiple times… and then they’d yell at customer support!

Optimistic UI is our weapon for protecting customer support. We know the shape of the data we expect to receive from the server, so why not fake it until we get a response? react-apollo lets us accomplish this by adding an optimisticResponse parameter to mutate. In our case it looks like this:

Changed client/src/screens/messages.screen.js
@@ -151,6 +151,24 @@
 ┊151┊151┊    createMessage: ({ text, userId, groupId }) =>
 ┊152┊152┊      mutate({
 ┊153┊153┊        variables: { text, userId, groupId },
+┊   ┊154┊        optimisticResponse: {
+┊   ┊155┊          __typename: 'Mutation',
+┊   ┊156┊          createMessage: {
+┊   ┊157┊            __typename: 'Message',
+┊   ┊158┊            id: -1, // don't know id yet, but it doesn't matter
+┊   ┊159┊            text, // we know what the text will be
+┊   ┊160┊            createdAt: new Date().toISOString(), // the time is now!
+┊   ┊161┊            from: {
+┊   ┊162┊              __typename: 'User',
+┊   ┊163┊              id: 1, // still faking the user
+┊   ┊164┊              username: 'Justyn.Kautzer', // still faking the user
+┊   ┊165┊            },
+┊   ┊166┊            to: {
+┊   ┊167┊              __typename: 'Group',
+┊   ┊168┊              id: groupId,
+┊   ┊169┊            },
+┊   ┊170┊          },
+┊   ┊171┊        },
 ┊154┊172┊        update: (store, { data: { createMessage } }) => {
 ┊155┊173┊          // Read the data from our cache for this query.
 ┊156┊174┊          const groupData = store.readQuery({

The Object returned from optimisticResponse is what the data should look like from our server when the mutation succeeds. We need to specify the __typename for all values in our optimistic response just like our server would. Even though we don’t know all values for all fields, we know enough to populate the ones that will show up in the UI, like the text, user, and message creation time. This will essentially be a placeholder until the server responds.

Let’s also modify our UI a bit so that our FlatList scrolls to the bottom when we send a message as soon as we receive new data:

Changed client/src/screens/messages.screen.js
@@ -74,6 +74,8 @@
 ┊74┊74┊      groupId: this.props.navigation.state.params.groupId,
 ┊75┊75┊      userId: 1, // faking the user for now
 ┊76┊76┊      text,
+┊  ┊77┊    }).then(() => {
+┊  ┊78┊      this.flatList.scrollToEnd({ animated: true });
 ┊77┊79┊    });
 ┊78┊80┊  }
 ┊79┊81┊
@@ -108,6 +110,7 @@
 ┊108┊110┊        style={styles.container}
 ┊109┊111┊      >
 ┊110┊112┊        <FlatList
+┊   ┊113┊          ref={(ref) => { this.flatList = ref; }}
 ┊111┊114┊          data={group.messages.slice().reverse()}
 ┊112┊115┊          keyExtractor={this.keyExtractor}
 ┊113┊116┊          renderItem={this.renderItem}

Scroll to Bottom Gif

🔥🔥🔥!!!

YOUR CHALLENGE

First, let’s take a break. We’ve definitely earned it.

Now that we’re comfortable using GraphQL Queries and Mutations and some tricky stuff in React Native, we can do most of the things we need to do for most basic applications. In fact, there are a number of Chatty features that we can already implement without knowing much else. This post is already plenty long, but there are features left to be built. So with that said, I like to suggest that you try to complete the following features on your own before we move on:

  1. Add GraphQL Mutations on our server for creating, modifying, and deleting Groups
  2. Add new Screens to our React Native app for creating, modifying, and deleting Groups
  3. Build GraphQL Queries and Mutations for our new Screens and connect them using react-apollo
  4. Include update for these new mutations where necessary

If you want to see some UI or you want a hint or you don’t wanna write any code, that’s cool too! Below is some code with these features added. Groups Gif

Changed client/package.json
@@ -15,6 +15,7 @@
 ┊15┊15┊		"apollo-link-redux": "^0.2.1",
 ┊16┊16┊		"graphql": "^0.12.3",
 ┊17┊17┊		"graphql-tag": "^2.4.2",
+┊  ┊18┊		"immutability-helper": "^2.6.4",
 ┊18┊19┊		"lodash": "^4.17.5",
 ┊19┊20┊		"moment": "^2.20.1",
 ┊20┊21┊		"prop-types": "^15.6.0",
@@ -22,6 +23,7 @@
 ┊22┊23┊		"react": "16.4.1",
 ┊23┊24┊		"react-apollo": "^2.0.4",
 ┊24┊25┊		"react-native": "0.56.0",
+┊  ┊26┊		"react-native-alpha-listview": "^0.2.1",
 ┊25┊27┊		"react-native-vector-icons": "^4.6.0",
 ┊26┊28┊		"react-navigation": "^1.0.3",
 ┊27┊29┊		"react-navigation-redux-helpers": "^1.1.2",
Added client/src/components/selected-user-list.component.js
@@ -0,0 +1,118 @@
+┊   ┊  1┊import React, { Component } from 'react';
+┊   ┊  2┊import PropTypes from 'prop-types';
+┊   ┊  3┊import {
+┊   ┊  4┊  FlatList,
+┊   ┊  5┊  Image,
+┊   ┊  6┊  StyleSheet,
+┊   ┊  7┊  Text,
+┊   ┊  8┊  TouchableOpacity,
+┊   ┊  9┊  View,
+┊   ┊ 10┊} from 'react-native';
+┊   ┊ 11┊import Icon from 'react-native-vector-icons/FontAwesome';
+┊   ┊ 12┊
+┊   ┊ 13┊const styles = StyleSheet.create({
+┊   ┊ 14┊  list: {
+┊   ┊ 15┊    paddingVertical: 8,
+┊   ┊ 16┊  },
+┊   ┊ 17┊  itemContainer: {
+┊   ┊ 18┊    alignItems: 'center',
+┊   ┊ 19┊    paddingHorizontal: 12,
+┊   ┊ 20┊  },
+┊   ┊ 21┊  itemIcon: {
+┊   ┊ 22┊    alignItems: 'center',
+┊   ┊ 23┊    backgroundColor: '#dbdbdb',
+┊   ┊ 24┊    borderColor: 'white',
+┊   ┊ 25┊    borderRadius: 10,
+┊   ┊ 26┊    borderWidth: 2,
+┊   ┊ 27┊    flexDirection: 'row',
+┊   ┊ 28┊    height: 20,
+┊   ┊ 29┊    justifyContent: 'center',
+┊   ┊ 30┊    position: 'absolute',
+┊   ┊ 31┊    right: -3,
+┊   ┊ 32┊    top: -3,
+┊   ┊ 33┊    width: 20,
+┊   ┊ 34┊  },
+┊   ┊ 35┊  itemImage: {
+┊   ┊ 36┊    borderRadius: 27,
+┊   ┊ 37┊    height: 54,
+┊   ┊ 38┊    width: 54,
+┊   ┊ 39┊  },
+┊   ┊ 40┊});
+┊   ┊ 41┊
+┊   ┊ 42┊export class SelectedUserListItem extends Component {
+┊   ┊ 43┊  constructor(props) {
+┊   ┊ 44┊    super(props);
+┊   ┊ 45┊
+┊   ┊ 46┊    this.remove = this.remove.bind(this);
+┊   ┊ 47┊  }
+┊   ┊ 48┊
+┊   ┊ 49┊  remove() {
+┊   ┊ 50┊    this.props.remove(this.props.user);
+┊   ┊ 51┊  }
+┊   ┊ 52┊
+┊   ┊ 53┊  render() {
+┊   ┊ 54┊    const { username } = this.props.user;
+┊   ┊ 55┊
+┊   ┊ 56┊    return (
+┊   ┊ 57┊      <View
+┊   ┊ 58┊        style={styles.itemContainer}
+┊   ┊ 59┊      >
+┊   ┊ 60┊        <View>
+┊   ┊ 61┊          <Image
+┊   ┊ 62┊            style={styles.itemImage}
+┊   ┊ 63┊            source={{ uri: 'https://reactjs.org/logo-og.png' }}
+┊   ┊ 64┊          />
+┊   ┊ 65┊          <TouchableOpacity onPress={this.remove} style={styles.itemIcon}>
+┊   ┊ 66┊            <Icon
+┊   ┊ 67┊              color="white"
+┊   ┊ 68┊              name="times"
+┊   ┊ 69┊              size={12}
+┊   ┊ 70┊            />
+┊   ┊ 71┊          </TouchableOpacity>
+┊   ┊ 72┊        </View>
+┊   ┊ 73┊        <Text>{username}</Text>
+┊   ┊ 74┊      </View>
+┊   ┊ 75┊    );
+┊   ┊ 76┊  }
+┊   ┊ 77┊}
+┊   ┊ 78┊SelectedUserListItem.propTypes = {
+┊   ┊ 79┊  user: PropTypes.shape({
+┊   ┊ 80┊    id: PropTypes.number,
+┊   ┊ 81┊    username: PropTypes.string,
+┊   ┊ 82┊  }),
+┊   ┊ 83┊  remove: PropTypes.func,
+┊   ┊ 84┊};
+┊   ┊ 85┊
+┊   ┊ 86┊class SelectedUserList extends Component {
+┊   ┊ 87┊  constructor(props) {
+┊   ┊ 88┊    super(props);
+┊   ┊ 89┊
+┊   ┊ 90┊    this.renderItem = this.renderItem.bind(this);
+┊   ┊ 91┊  }
+┊   ┊ 92┊
+┊   ┊ 93┊  keyExtractor = item => item.id.toString();
+┊   ┊ 94┊
+┊   ┊ 95┊  renderItem({ item: user }) {
+┊   ┊ 96┊    return (
+┊   ┊ 97┊      <SelectedUserListItem user={user} remove={this.props.remove} />
+┊   ┊ 98┊    );
+┊   ┊ 99┊  }
+┊   ┊100┊
+┊   ┊101┊  render() {
+┊   ┊102┊    return (
+┊   ┊103┊      <FlatList
+┊   ┊104┊        data={this.props.data}
+┊   ┊105┊        keyExtractor={this.keyExtractor}
+┊   ┊106┊        renderItem={this.renderItem}
+┊   ┊107┊        horizontal
+┊   ┊108┊        style={styles.list}
+┊   ┊109┊      />
+┊   ┊110┊    );
+┊   ┊111┊  }
+┊   ┊112┊}
+┊   ┊113┊SelectedUserList.propTypes = {
+┊   ┊114┊  data: PropTypes.arrayOf(PropTypes.object),
+┊   ┊115┊  remove: PropTypes.func,
+┊   ┊116┊};
+┊   ┊117┊
+┊   ┊118┊export default SelectedUserList;
Added client/src/graphql/create-group.mutation.js
@@ -0,0 +1,15 @@
+┊  ┊ 1┊import gql from 'graphql-tag';
+┊  ┊ 2┊
+┊  ┊ 3┊const CREATE_GROUP_MUTATION = gql`
+┊  ┊ 4┊  mutation createGroup($name: String!, $userIds: [Int!], $userId: Int!) {
+┊  ┊ 5┊    createGroup(name: $name, userIds: $userIds, userId: $userId) {
+┊  ┊ 6┊      id
+┊  ┊ 7┊      name
+┊  ┊ 8┊      users {
+┊  ┊ 9┊        id
+┊  ┊10┊      }
+┊  ┊11┊    }
+┊  ┊12┊  }
+┊  ┊13┊`;
+┊  ┊14┊
+┊  ┊15┊export default CREATE_GROUP_MUTATION;
Added client/src/graphql/delete-group.mutation.js
@@ -0,0 +1,11 @@
+┊  ┊ 1┊import gql from 'graphql-tag';
+┊  ┊ 2┊
+┊  ┊ 3┊const DELETE_GROUP_MUTATION = gql`
+┊  ┊ 4┊  mutation deleteGroup($id: Int!) {
+┊  ┊ 5┊    deleteGroup(id: $id) {
+┊  ┊ 6┊      id
+┊  ┊ 7┊    }
+┊  ┊ 8┊  }
+┊  ┊ 9┊`;
+┊  ┊10┊
+┊  ┊11┊export default DELETE_GROUP_MUTATION;
Added client/src/graphql/leave-group.mutation.js
@@ -0,0 +1,11 @@
+┊  ┊ 1┊import gql from 'graphql-tag';
+┊  ┊ 2┊
+┊  ┊ 3┊const LEAVE_GROUP_MUTATION = gql`
+┊  ┊ 4┊  mutation leaveGroup($id: Int!, $userId: Int!) {
+┊  ┊ 5┊    leaveGroup(id: $id, userId: $userId) {
+┊  ┊ 6┊      id
+┊  ┊ 7┊    }
+┊  ┊ 8┊  }
+┊  ┊ 9┊`;
+┊  ┊10┊
+┊  ┊11┊export default LEAVE_GROUP_MUTATION;
Changed client/src/graphql/user.query.js
@@ -11,6 +11,10 @@
 ┊11┊11┊        id
 ┊12┊12┊        name
 ┊13┊13┊      }
+┊  ┊14┊      friends {
+┊  ┊15┊        id
+┊  ┊16┊        username
+┊  ┊17┊      }
 ┊14┊18┊    }
 ┊15┊19┊  }
 ┊16┊20┊`;
Changed client/src/navigation.js
@@ -10,6 +10,9 @@
 ┊10┊10┊
 ┊11┊11┊import Groups from './screens/groups.screen';
 ┊12┊12┊import Messages from './screens/messages.screen';
+┊  ┊13┊import FinalizeGroup from './screens/finalize-group.screen';
+┊  ┊14┊import GroupDetails from './screens/group-details.screen';
+┊  ┊15┊import NewGroup from './screens/new-group.screen';
 ┊13┊16┊
 ┊14┊17┊const styles = StyleSheet.create({
 ┊15┊18┊  container: {
@@ -47,6 +50,9 @@
 ┊47┊50┊const AppNavigator = StackNavigator({
 ┊48┊51┊  Main: { screen: MainScreenNavigator },
 ┊49┊52┊  Messages: { screen: Messages },
+┊  ┊53┊  GroupDetails: { screen: GroupDetails },
+┊  ┊54┊  NewGroup: { screen: NewGroup },
+┊  ┊55┊  FinalizeGroup: { screen: FinalizeGroup },
 ┊50┊56┊}, {
 ┊51┊57┊  mode: 'modal',
 ┊52┊58┊});
Added client/src/screens/finalize-group.screen.js
@@ -0,0 +1,261 @@
+┊   ┊  1┊import { _ } from 'lodash';
+┊   ┊  2┊import React, { Component } from 'react';
+┊   ┊  3┊import PropTypes from 'prop-types';
+┊   ┊  4┊import {
+┊   ┊  5┊  Alert,
+┊   ┊  6┊  Button,
+┊   ┊  7┊  Image,
+┊   ┊  8┊  StyleSheet,
+┊   ┊  9┊  Text,
+┊   ┊ 10┊  TextInput,
+┊   ┊ 11┊  TouchableOpacity,
+┊   ┊ 12┊  View,
+┊   ┊ 13┊} from 'react-native';
+┊   ┊ 14┊import { graphql, compose } from 'react-apollo';
+┊   ┊ 15┊import { NavigationActions } from 'react-navigation';
+┊   ┊ 16┊import update from 'immutability-helper';
+┊   ┊ 17┊
+┊   ┊ 18┊import { USER_QUERY } from '../graphql/user.query';
+┊   ┊ 19┊import CREATE_GROUP_MUTATION from '../graphql/create-group.mutation';
+┊   ┊ 20┊import SelectedUserList from '../components/selected-user-list.component';
+┊   ┊ 21┊
+┊   ┊ 22┊const goToNewGroup = group => NavigationActions.reset({
+┊   ┊ 23┊  index: 1,
+┊   ┊ 24┊  actions: [
+┊   ┊ 25┊    NavigationActions.navigate({ routeName: 'Main' }),
+┊   ┊ 26┊    NavigationActions.navigate({ routeName: 'Messages', params: { groupId: group.id, title: group.name } }),
+┊   ┊ 27┊  ],
+┊   ┊ 28┊});
+┊   ┊ 29┊
+┊   ┊ 30┊const styles = StyleSheet.create({
+┊   ┊ 31┊  container: {
+┊   ┊ 32┊    flex: 1,
+┊   ┊ 33┊    backgroundColor: 'white',
+┊   ┊ 34┊  },
+┊   ┊ 35┊  detailsContainer: {
+┊   ┊ 36┊    padding: 20,
+┊   ┊ 37┊    flexDirection: 'row',
+┊   ┊ 38┊  },
+┊   ┊ 39┊  imageContainer: {
+┊   ┊ 40┊    paddingRight: 20,
+┊   ┊ 41┊    alignItems: 'center',
+┊   ┊ 42┊  },
+┊   ┊ 43┊  inputContainer: {
+┊   ┊ 44┊    flexDirection: 'column',
+┊   ┊ 45┊    flex: 1,
+┊   ┊ 46┊  },
+┊   ┊ 47┊  input: {
+┊   ┊ 48┊    color: 'black',
+┊   ┊ 49┊    height: 32,
+┊   ┊ 50┊  },
+┊   ┊ 51┊  inputBorder: {
+┊   ┊ 52┊    borderColor: '#dbdbdb',
+┊   ┊ 53┊    borderBottomWidth: 1,
+┊   ┊ 54┊    borderTopWidth: 1,
+┊   ┊ 55┊    paddingVertical: 8,
+┊   ┊ 56┊  },
+┊   ┊ 57┊  inputInstructions: {
+┊   ┊ 58┊    paddingTop: 6,
+┊   ┊ 59┊    color: '#777',
+┊   ┊ 60┊    fontSize: 12,
+┊   ┊ 61┊  },
+┊   ┊ 62┊  groupImage: {
+┊   ┊ 63┊    width: 54,
+┊   ┊ 64┊    height: 54,
+┊   ┊ 65┊    borderRadius: 27,
+┊   ┊ 66┊  },
+┊   ┊ 67┊  selected: {
+┊   ┊ 68┊    flexDirection: 'row',
+┊   ┊ 69┊  },
+┊   ┊ 70┊  loading: {
+┊   ┊ 71┊    justifyContent: 'center',
+┊   ┊ 72┊    flex: 1,
+┊   ┊ 73┊  },
+┊   ┊ 74┊  navIcon: {
+┊   ┊ 75┊    color: 'blue',
+┊   ┊ 76┊    fontSize: 18,
+┊   ┊ 77┊    paddingTop: 2,
+┊   ┊ 78┊  },
+┊   ┊ 79┊  participants: {
+┊   ┊ 80┊    paddingHorizontal: 20,
+┊   ┊ 81┊    paddingVertical: 6,
+┊   ┊ 82┊    backgroundColor: '#dbdbdb',
+┊   ┊ 83┊    color: '#777',
+┊   ┊ 84┊  },
+┊   ┊ 85┊});
+┊   ┊ 86┊
+┊   ┊ 87┊class FinalizeGroup extends Component {
+┊   ┊ 88┊  static navigationOptions = ({ navigation }) => {
+┊   ┊ 89┊    const { state } = navigation;
+┊   ┊ 90┊    const isReady = state.params && state.params.mode === 'ready';
+┊   ┊ 91┊    return {
+┊   ┊ 92┊      title: 'New Group',
+┊   ┊ 93┊      headerRight: (
+┊   ┊ 94┊        isReady ? <Button
+┊   ┊ 95┊          title="Create"
+┊   ┊ 96┊          onPress={state.params.create}
+┊   ┊ 97┊        /> : undefined
+┊   ┊ 98┊      ),
+┊   ┊ 99┊    };
+┊   ┊100┊  };
+┊   ┊101┊
+┊   ┊102┊  constructor(props) {
+┊   ┊103┊    super(props);
+┊   ┊104┊
+┊   ┊105┊    const { selected } = props.navigation.state.params;
+┊   ┊106┊
+┊   ┊107┊    this.state = {
+┊   ┊108┊      selected,
+┊   ┊109┊    };
+┊   ┊110┊
+┊   ┊111┊    this.create = this.create.bind(this);
+┊   ┊112┊    this.pop = this.pop.bind(this);
+┊   ┊113┊    this.remove = this.remove.bind(this);
+┊   ┊114┊  }
+┊   ┊115┊
+┊   ┊116┊  componentDidMount() {
+┊   ┊117┊    this.refreshNavigation(this.state.selected.length && this.state.name);
+┊   ┊118┊  }
+┊   ┊119┊
+┊   ┊120┊  componentWillUpdate(nextProps, nextState) {
+┊   ┊121┊    if ((nextState.selected.length && nextState.name) !==
+┊   ┊122┊      (this.state.selected.length && this.state.name)) {
+┊   ┊123┊      this.refreshNavigation(nextState.selected.length && nextState.name);
+┊   ┊124┊    }
+┊   ┊125┊  }
+┊   ┊126┊
+┊   ┊127┊  pop() {
+┊   ┊128┊    this.props.navigation.goBack();
+┊   ┊129┊  }
+┊   ┊130┊
+┊   ┊131┊  remove(user) {
+┊   ┊132┊    const index = this.state.selected.indexOf(user);
+┊   ┊133┊    if (~index) {
+┊   ┊134┊      const selected = update(this.state.selected, { $splice: [[index, 1]] });
+┊   ┊135┊      this.setState({
+┊   ┊136┊        selected,
+┊   ┊137┊      });
+┊   ┊138┊    }
+┊   ┊139┊  }
+┊   ┊140┊
+┊   ┊141┊  create() {
+┊   ┊142┊    const { createGroup } = this.props;
+┊   ┊143┊
+┊   ┊144┊    createGroup({
+┊   ┊145┊      name: this.state.name,
+┊   ┊146┊      userId: 1, // fake user for now
+┊   ┊147┊      userIds: _.map(this.state.selected, 'id'),
+┊   ┊148┊    }).then((res) => {
+┊   ┊149┊      this.props.navigation.dispatch(goToNewGroup(res.data.createGroup));
+┊   ┊150┊    }).catch((error) => {
+┊   ┊151┊      Alert.alert(
+┊   ┊152┊        'Error Creating New Group',
+┊   ┊153┊        error.message,
+┊   ┊154┊        [
+┊   ┊155┊          { text: 'OK', onPress: () => {} },
+┊   ┊156┊        ],
+┊   ┊157┊      );
+┊   ┊158┊    });
+┊   ┊159┊  }
+┊   ┊160┊
+┊   ┊161┊  refreshNavigation(ready) {
+┊   ┊162┊    const { navigation } = this.props;
+┊   ┊163┊    navigation.setParams({
+┊   ┊164┊      mode: ready ? 'ready' : undefined,
+┊   ┊165┊      create: this.create,
+┊   ┊166┊    });
+┊   ┊167┊  }
+┊   ┊168┊
+┊   ┊169┊  render() {
+┊   ┊170┊    const { friendCount } = this.props.navigation.state.params;
+┊   ┊171┊
+┊   ┊172┊    return (
+┊   ┊173┊      <View style={styles.container}>
+┊   ┊174┊        <View style={styles.detailsContainer}>
+┊   ┊175┊          <TouchableOpacity style={styles.imageContainer}>
+┊   ┊176┊            <Image
+┊   ┊177┊              style={styles.groupImage}
+┊   ┊178┊              source={{ uri: 'https://reactjs.org/logo-og.png' }}
+┊   ┊179┊            />
+┊   ┊180┊            <Text>edit</Text>
+┊   ┊181┊          </TouchableOpacity>
+┊   ┊182┊          <View style={styles.inputContainer}>
+┊   ┊183┊            <View style={styles.inputBorder}>
+┊   ┊184┊              <TextInput
+┊   ┊185┊                autoFocus
+┊   ┊186┊                onChangeText={name => this.setState({ name })}
+┊   ┊187┊                placeholder="Group Subject"
+┊   ┊188┊                style={styles.input}
+┊   ┊189┊              />
+┊   ┊190┊            </View>
+┊   ┊191┊            <Text style={styles.inputInstructions}>
+┊   ┊192┊              {'Please provide a group subject and optional group icon'}
+┊   ┊193┊            </Text>
+┊   ┊194┊          </View>
+┊   ┊195┊        </View>
+┊   ┊196┊        <Text style={styles.participants}>
+┊   ┊197┊          {`participants: ${this.state.selected.length} of ${friendCount}`.toUpperCase()}
+┊   ┊198┊        </Text>
+┊   ┊199┊        <View style={styles.selected}>
+┊   ┊200┊          {this.state.selected.length ?
+┊   ┊201┊            <SelectedUserList
+┊   ┊202┊              data={this.state.selected}
+┊   ┊203┊              remove={this.remove}
+┊   ┊204┊            /> : undefined}
+┊   ┊205┊        </View>
+┊   ┊206┊      </View>
+┊   ┊207┊    );
+┊   ┊208┊  }
+┊   ┊209┊}
+┊   ┊210┊
+┊   ┊211┊FinalizeGroup.propTypes = {
+┊   ┊212┊  createGroup: PropTypes.func.isRequired,
+┊   ┊213┊  navigation: PropTypes.shape({
+┊   ┊214┊    dispatch: PropTypes.func,
+┊   ┊215┊    goBack: PropTypes.func,
+┊   ┊216┊    state: PropTypes.shape({
+┊   ┊217┊      params: PropTypes.shape({
+┊   ┊218┊        friendCount: PropTypes.number.isRequired,
+┊   ┊219┊      }),
+┊   ┊220┊    }),
+┊   ┊221┊  }),
+┊   ┊222┊};
+┊   ┊223┊
+┊   ┊224┊const createGroupMutation = graphql(CREATE_GROUP_MUTATION, {
+┊   ┊225┊  props: ({ mutate }) => ({
+┊   ┊226┊    createGroup: ({ name, userIds, userId }) =>
+┊   ┊227┊      mutate({
+┊   ┊228┊        variables: { name, userIds, userId },
+┊   ┊229┊        update: (store, { data: { createGroup } }) => {
+┊   ┊230┊          // Read the data from our cache for this query.
+┊   ┊231┊          const data = store.readQuery({ query: USER_QUERY, variables: { id: userId } });
+┊   ┊232┊
+┊   ┊233┊          // Add our message from the mutation to the end.
+┊   ┊234┊          data.user.groups.push(createGroup);
+┊   ┊235┊
+┊   ┊236┊          // Write our data back to the cache.
+┊   ┊237┊          store.writeQuery({
+┊   ┊238┊            query: USER_QUERY,
+┊   ┊239┊            variables: { id: userId },
+┊   ┊240┊            data,
+┊   ┊241┊          });
+┊   ┊242┊        },
+┊   ┊243┊      }),
+┊   ┊244┊  }),
+┊   ┊245┊});
+┊   ┊246┊
+┊   ┊247┊const userQuery = graphql(USER_QUERY, {
+┊   ┊248┊  options: ownProps => ({
+┊   ┊249┊    variables: {
+┊   ┊250┊      id: ownProps.navigation.state.params.userId,
+┊   ┊251┊    },
+┊   ┊252┊  }),
+┊   ┊253┊  props: ({ data: { loading, user } }) => ({
+┊   ┊254┊    loading, user,
+┊   ┊255┊  }),
+┊   ┊256┊});
+┊   ┊257┊
+┊   ┊258┊export default compose(
+┊   ┊259┊  userQuery,
+┊   ┊260┊  createGroupMutation,
+┊   ┊261┊)(FinalizeGroup);
Added client/src/screens/group-details.screen.js
@@ -0,0 +1,265 @@
+┊   ┊  1┊// TODO: update group functionality
+┊   ┊  2┊import React, { Component } from 'react';
+┊   ┊  3┊import PropTypes from 'prop-types';
+┊   ┊  4┊import {
+┊   ┊  5┊  ActivityIndicator,
+┊   ┊  6┊  Button,
+┊   ┊  7┊  Image,
+┊   ┊  8┊  FlatList,
+┊   ┊  9┊  StyleSheet,
+┊   ┊ 10┊  Text,
+┊   ┊ 11┊  TouchableOpacity,
+┊   ┊ 12┊  View,
+┊   ┊ 13┊} from 'react-native';
+┊   ┊ 14┊import { graphql, compose } from 'react-apollo';
+┊   ┊ 15┊import { NavigationActions } from 'react-navigation';
+┊   ┊ 16┊
+┊   ┊ 17┊import GROUP_QUERY from '../graphql/group.query';
+┊   ┊ 18┊import USER_QUERY from '../graphql/user.query';
+┊   ┊ 19┊import DELETE_GROUP_MUTATION from '../graphql/delete-group.mutation';
+┊   ┊ 20┊import LEAVE_GROUP_MUTATION from '../graphql/leave-group.mutation';
+┊   ┊ 21┊
+┊   ┊ 22┊const resetAction = NavigationActions.reset({
+┊   ┊ 23┊  index: 0,
+┊   ┊ 24┊  actions: [
+┊   ┊ 25┊    NavigationActions.navigate({ routeName: 'Main' }),
+┊   ┊ 26┊  ],
+┊   ┊ 27┊});
+┊   ┊ 28┊
+┊   ┊ 29┊const styles = StyleSheet.create({
+┊   ┊ 30┊  container: {
+┊   ┊ 31┊    flex: 1,
+┊   ┊ 32┊  },
+┊   ┊ 33┊  avatar: {
+┊   ┊ 34┊    width: 32,
+┊   ┊ 35┊    height: 32,
+┊   ┊ 36┊    borderRadius: 16,
+┊   ┊ 37┊  },
+┊   ┊ 38┊  detailsContainer: {
+┊   ┊ 39┊    flexDirection: 'row',
+┊   ┊ 40┊    alignItems: 'center',
+┊   ┊ 41┊  },
+┊   ┊ 42┊  groupImageContainer: {
+┊   ┊ 43┊    paddingTop: 20,
+┊   ┊ 44┊    paddingHorizontal: 20,
+┊   ┊ 45┊    paddingBottom: 6,
+┊   ┊ 46┊    alignItems: 'center',
+┊   ┊ 47┊  },
+┊   ┊ 48┊  groupName: {
+┊   ┊ 49┊    color: 'black',
+┊   ┊ 50┊  },
+┊   ┊ 51┊  groupNameBorder: {
+┊   ┊ 52┊    borderBottomWidth: 1,
+┊   ┊ 53┊    borderColor: '#dbdbdb',
+┊   ┊ 54┊    borderTopWidth: 1,
+┊   ┊ 55┊    flex: 1,
+┊   ┊ 56┊    paddingVertical: 8,
+┊   ┊ 57┊  },
+┊   ┊ 58┊  groupImage: {
+┊   ┊ 59┊    width: 54,
+┊   ┊ 60┊    height: 54,
+┊   ┊ 61┊    borderRadius: 27,
+┊   ┊ 62┊  },
+┊   ┊ 63┊  participants: {
+┊   ┊ 64┊    borderBottomWidth: 1,
+┊   ┊ 65┊    borderColor: '#dbdbdb',
+┊   ┊ 66┊    borderTopWidth: 1,
+┊   ┊ 67┊    paddingHorizontal: 20,
+┊   ┊ 68┊    paddingVertical: 6,
+┊   ┊ 69┊    backgroundColor: '#dbdbdb',
+┊   ┊ 70┊    color: '#777',
+┊   ┊ 71┊  },
+┊   ┊ 72┊  user: {
+┊   ┊ 73┊    alignItems: 'center',
+┊   ┊ 74┊    borderBottomWidth: 1,
+┊   ┊ 75┊    borderColor: '#dbdbdb',
+┊   ┊ 76┊    flexDirection: 'row',
+┊   ┊ 77┊    padding: 10,
+┊   ┊ 78┊  },
+┊   ┊ 79┊  username: {
+┊   ┊ 80┊    flex: 1,
+┊   ┊ 81┊    fontSize: 16,
+┊   ┊ 82┊    paddingHorizontal: 12,
+┊   ┊ 83┊    paddingVertical: 8,
+┊   ┊ 84┊  },
+┊   ┊ 85┊});
+┊   ┊ 86┊
+┊   ┊ 87┊class GroupDetails extends Component {
+┊   ┊ 88┊  static navigationOptions = ({ navigation }) => ({
+┊   ┊ 89┊    title: `${navigation.state.params.title}`,
+┊   ┊ 90┊  });
+┊   ┊ 91┊
+┊   ┊ 92┊  constructor(props) {
+┊   ┊ 93┊    super(props);
+┊   ┊ 94┊
+┊   ┊ 95┊    this.deleteGroup = this.deleteGroup.bind(this);
+┊   ┊ 96┊    this.leaveGroup = this.leaveGroup.bind(this);
+┊   ┊ 97┊    this.renderItem = this.renderItem.bind(this);
+┊   ┊ 98┊  }
+┊   ┊ 99┊
+┊   ┊100┊  deleteGroup() {
+┊   ┊101┊    this.props.deleteGroup(this.props.navigation.state.params.id)
+┊   ┊102┊      .then(() => {
+┊   ┊103┊        this.props.navigation.dispatch(resetAction);
+┊   ┊104┊      })
+┊   ┊105┊      .catch((e) => {
+┊   ┊106┊        console.log(e); // eslint-disable-line no-console
+┊   ┊107┊      });
+┊   ┊108┊  }
+┊   ┊109┊
+┊   ┊110┊  leaveGroup() {
+┊   ┊111┊    this.props.leaveGroup({
+┊   ┊112┊      id: this.props.navigation.state.params.id,
+┊   ┊113┊      userId: 1,
+┊   ┊114┊    }) // fake user for now
+┊   ┊115┊      .then(() => {
+┊   ┊116┊        this.props.navigation.dispatch(resetAction);
+┊   ┊117┊      })
+┊   ┊118┊      .catch((e) => {
+┊   ┊119┊        console.log(e); // eslint-disable-line no-console
+┊   ┊120┊      });
+┊   ┊121┊  }
+┊   ┊122┊
+┊   ┊123┊  keyExtractor = item => item.id.toString();
+┊   ┊124┊
+┊   ┊125┊  renderItem = ({ item: user }) => (
+┊   ┊126┊    <View style={styles.user}>
+┊   ┊127┊      <Image
+┊   ┊128┊        style={styles.avatar}
+┊   ┊129┊        source={{ uri: 'https://reactjs.org/logo-og.png' }}
+┊   ┊130┊      />
+┊   ┊131┊      <Text style={styles.username}>{user.username}</Text>
+┊   ┊132┊    </View>
+┊   ┊133┊  )
+┊   ┊134┊
+┊   ┊135┊  render() {
+┊   ┊136┊    const { group, loading } = this.props;
+┊   ┊137┊
+┊   ┊138┊    // render loading placeholder while we fetch messages
+┊   ┊139┊    if (!group || loading) {
+┊   ┊140┊      return (
+┊   ┊141┊        <View style={[styles.loading, styles.container]}>
+┊   ┊142┊          <ActivityIndicator />
+┊   ┊143┊        </View>
+┊   ┊144┊      );
+┊   ┊145┊    }
+┊   ┊146┊
+┊   ┊147┊    return (
+┊   ┊148┊      <View style={styles.container}>
+┊   ┊149┊        <FlatList
+┊   ┊150┊          data={group.users}
+┊   ┊151┊          keyExtractor={this.keyExtractor}
+┊   ┊152┊          renderItem={this.renderItem}
+┊   ┊153┊          ListHeaderComponent={() => (
+┊   ┊154┊            <View>
+┊   ┊155┊              <View style={styles.detailsContainer}>
+┊   ┊156┊                <TouchableOpacity style={styles.groupImageContainer} onPress={this.pickGroupImage}>
+┊   ┊157┊                  <Image
+┊   ┊158┊                    style={styles.groupImage}
+┊   ┊159┊                    source={{ uri: 'https://reactjs.org/logo-og.png' }}
+┊   ┊160┊                  />
+┊   ┊161┊                  <Text>edit</Text>
+┊   ┊162┊                </TouchableOpacity>
+┊   ┊163┊                <View style={styles.groupNameBorder}>
+┊   ┊164┊                  <Text style={styles.groupName}>{group.name}</Text>
+┊   ┊165┊                </View>
+┊   ┊166┊              </View>
+┊   ┊167┊              <Text style={styles.participants}>
+┊   ┊168┊                {`participants: ${group.users.length}`.toUpperCase()}
+┊   ┊169┊              </Text>
+┊   ┊170┊            </View>
+┊   ┊171┊          )}
+┊   ┊172┊          ListFooterComponent={() => (
+┊   ┊173┊            <View>
+┊   ┊174┊              <Button title="Leave Group" onPress={this.leaveGroup} />
+┊   ┊175┊              <Button title="Delete Group" onPress={this.deleteGroup} />
+┊   ┊176┊            </View>
+┊   ┊177┊          )}
+┊   ┊178┊        />
+┊   ┊179┊      </View>
+┊   ┊180┊    );
+┊   ┊181┊  }
+┊   ┊182┊}
+┊   ┊183┊
+┊   ┊184┊GroupDetails.propTypes = {
+┊   ┊185┊  loading: PropTypes.bool,
+┊   ┊186┊  group: PropTypes.shape({
+┊   ┊187┊    id: PropTypes.number,
+┊   ┊188┊    name: PropTypes.string,
+┊   ┊189┊    users: PropTypes.arrayOf(PropTypes.shape({
+┊   ┊190┊      id: PropTypes.number,
+┊   ┊191┊      username: PropTypes.string,
+┊   ┊192┊    })),
+┊   ┊193┊  }),
+┊   ┊194┊  navigation: PropTypes.shape({
+┊   ┊195┊    dispatch: PropTypes.func,
+┊   ┊196┊    state: PropTypes.shape({
+┊   ┊197┊      params: PropTypes.shape({
+┊   ┊198┊        title: PropTypes.string,
+┊   ┊199┊        id: PropTypes.number,
+┊   ┊200┊      }),
+┊   ┊201┊    }),
+┊   ┊202┊  }),
+┊   ┊203┊  deleteGroup: PropTypes.func.isRequired,
+┊   ┊204┊  leaveGroup: PropTypes.func.isRequired,
+┊   ┊205┊};
+┊   ┊206┊
+┊   ┊207┊const groupQuery = graphql(GROUP_QUERY, {
+┊   ┊208┊  options: ownProps => ({ variables: { groupId: ownProps.navigation.state.params.id } }),
+┊   ┊209┊  props: ({ data: { loading, group } }) => ({
+┊   ┊210┊    loading,
+┊   ┊211┊    group,
+┊   ┊212┊  }),
+┊   ┊213┊});
+┊   ┊214┊
+┊   ┊215┊const deleteGroupMutation = graphql(DELETE_GROUP_MUTATION, {
+┊   ┊216┊  props: ({ ownProps, mutate }) => ({
+┊   ┊217┊    deleteGroup: id =>
+┊   ┊218┊      mutate({
+┊   ┊219┊        variables: { id },
+┊   ┊220┊        update: (store, { data: { deleteGroup } }) => {
+┊   ┊221┊          // Read the data from our cache for this query.
+┊   ┊222┊          const data = store.readQuery({ query: USER_QUERY, variables: { id: 1 } }); // fake for now
+┊   ┊223┊
+┊   ┊224┊          // Add our message from the mutation to the end.
+┊   ┊225┊          data.user.groups = data.user.groups.filter(g => deleteGroup.id !== g.id);
+┊   ┊226┊
+┊   ┊227┊          // Write our data back to the cache.
+┊   ┊228┊          store.writeQuery({
+┊   ┊229┊            query: USER_QUERY,
+┊   ┊230┊            variables: { id: 1 }, // fake for now
+┊   ┊231┊            data,
+┊   ┊232┊          });
+┊   ┊233┊        },
+┊   ┊234┊      }),
+┊   ┊235┊  }),
+┊   ┊236┊});
+┊   ┊237┊
+┊   ┊238┊const leaveGroupMutation = graphql(LEAVE_GROUP_MUTATION, {
+┊   ┊239┊  props: ({ ownProps, mutate }) => ({
+┊   ┊240┊    leaveGroup: ({ id, userId }) =>
+┊   ┊241┊      mutate({
+┊   ┊242┊        variables: { id, userId },
+┊   ┊243┊        update: (store, { data: { leaveGroup } }) => {
+┊   ┊244┊          // Read the data from our cache for this query.
+┊   ┊245┊          const data = store.readQuery({ query: USER_QUERY, variables: { id: 1 } }); // fake for now
+┊   ┊246┊
+┊   ┊247┊          // Add our message from the mutation to the end.
+┊   ┊248┊          data.user.groups = data.user.groups.filter(g => leaveGroup.id !== g.id);
+┊   ┊249┊
+┊   ┊250┊          // Write our data back to the cache.
+┊   ┊251┊          store.writeQuery({
+┊   ┊252┊            query: USER_QUERY,
+┊   ┊253┊            variables: { id: 1 }, // fake for now
+┊   ┊254┊            data,
+┊   ┊255┊          });
+┊   ┊256┊        },
+┊   ┊257┊      }),
+┊   ┊258┊  }),
+┊   ┊259┊});
+┊   ┊260┊
+┊   ┊261┊export default compose(
+┊   ┊262┊  groupQuery,
+┊   ┊263┊  deleteGroupMutation,
+┊   ┊264┊  leaveGroupMutation,
+┊   ┊265┊)(GroupDetails);
Changed client/src/screens/groups.screen.js
@@ -3,6 +3,7 @@
 ┊3┊3┊import {
 ┊4┊4┊  FlatList,
 ┊5┊5┊  ActivityIndicator,
+┊ ┊6┊  Button,
 ┊6┊7┊  StyleSheet,
 ┊7┊8┊  Text,
 ┊8┊9┊  TouchableHighlight,
@@ -35,8 +36,27 @@
 ┊35┊36┊    fontWeight: 'bold',
 ┊36┊37┊    flex: 0.7,
 ┊37┊38┊  },
+┊  ┊39┊  header: {
+┊  ┊40┊    alignItems: 'flex-end',
+┊  ┊41┊    padding: 6,
+┊  ┊42┊    borderColor: '#eee',
+┊  ┊43┊    borderBottomWidth: 1,
+┊  ┊44┊  },
+┊  ┊45┊  warning: {
+┊  ┊46┊    textAlign: 'center',
+┊  ┊47┊    padding: 12,
+┊  ┊48┊  },
 ┊38┊49┊});
 ┊39┊50┊
+┊  ┊51┊const Header = ({ onPress }) => (
+┊  ┊52┊  <View style={styles.header}>
+┊  ┊53┊    <Button title={'New Group'} onPress={onPress} />
+┊  ┊54┊  </View>
+┊  ┊55┊);
+┊  ┊56┊Header.propTypes = {
+┊  ┊57┊  onPress: PropTypes.func.isRequired,
+┊  ┊58┊};
+┊  ┊59┊
 ┊40┊60┊class Group extends Component {
 ┊41┊61┊  constructor(props) {
 ┊42┊62┊    super(props);
@@ -75,6 +95,7 @@
 ┊ 75┊ 95┊  constructor(props) {
 ┊ 76┊ 96┊    super(props);
 ┊ 77┊ 97┊    this.goToMessages = this.goToMessages.bind(this);
+┊   ┊ 98┊    this.goToNewGroup = this.goToNewGroup.bind(this);
 ┊ 78┊ 99┊  }
 ┊ 79┊100┊
 ┊ 80┊101┊  keyExtractor = item => item.id.toString();
@@ -84,6 +105,11 @@
 ┊ 84┊105┊    navigate('Messages', { groupId: group.id, title: group.name });
 ┊ 85┊106┊  }
 ┊ 86┊107┊
+┊   ┊108┊  goToNewGroup() {
+┊   ┊109┊    const { navigate } = this.props.navigation;
+┊   ┊110┊    navigate('NewGroup');
+┊   ┊111┊  }
+┊   ┊112┊
 ┊ 87┊113┊  renderItem = ({ item }) => <Group group={item} goToMessages={this.goToMessages} />;
 ┊ 88┊114┊
 ┊ 89┊115┊  render() {
@@ -98,6 +124,15 @@
 ┊ 98┊124┊      );
 ┊ 99┊125┊    }
 ┊100┊126┊
+┊   ┊127┊    if (user && !user.groups.length) {
+┊   ┊128┊      return (
+┊   ┊129┊        <View style={styles.container}>
+┊   ┊130┊          <Header onPress={this.goToNewGroup} />
+┊   ┊131┊          <Text style={styles.warning}>{'You do not have any groups.'}</Text>
+┊   ┊132┊        </View>
+┊   ┊133┊      );
+┊   ┊134┊    }
+┊   ┊135┊
 ┊101┊136┊    // render list of groups for user
 ┊102┊137┊    return (
 ┊103┊138┊      <View style={styles.container}>
@@ -105,6 +140,7 @@
 ┊105┊140┊          data={user.groups}
 ┊106┊141┊          keyExtractor={this.keyExtractor}
 ┊107┊142┊          renderItem={this.renderItem}
+┊   ┊143┊          ListHeaderComponent={() => <Header onPress={this.goToNewGroup} />}
 ┊108┊144┊        />
 ┊109┊145┊      </View>
 ┊110┊146┊    );
Changed client/src/screens/messages.screen.js
@@ -1,8 +1,11 @@
 ┊ 1┊ 1┊import {
 ┊ 2┊ 2┊  ActivityIndicator,
 ┊ 3┊ 3┊  FlatList,
+┊  ┊ 4┊  Image,
 ┊ 4┊ 5┊  KeyboardAvoidingView,
 ┊ 5┊ 6┊  StyleSheet,
+┊  ┊ 7┊  Text,
+┊  ┊ 8┊  TouchableOpacity,
 ┊ 6┊ 9┊  View,
 ┊ 7┊10┊} from 'react-native';
 ┊ 8┊11┊import PropTypes from 'prop-types';
@@ -25,13 +28,48 @@
 ┊25┊28┊  loading: {
 ┊26┊29┊    justifyContent: 'center',
 ┊27┊30┊  },
+┊  ┊31┊  titleWrapper: {
+┊  ┊32┊    alignItems: 'center',
+┊  ┊33┊    position: 'absolute',
+┊  ┊34┊    left: 0,
+┊  ┊35┊    right: 0,
+┊  ┊36┊  },
+┊  ┊37┊  title: {
+┊  ┊38┊    flexDirection: 'row',
+┊  ┊39┊    alignItems: 'center',
+┊  ┊40┊  },
+┊  ┊41┊  titleImage: {
+┊  ┊42┊    marginRight: 6,
+┊  ┊43┊    width: 32,
+┊  ┊44┊    height: 32,
+┊  ┊45┊    borderRadius: 16,
+┊  ┊46┊  },
 ┊28┊47┊});
 ┊29┊48┊
 ┊30┊49┊class Messages extends Component {
 ┊31┊50┊  static navigationOptions = ({ navigation }) => {
-┊32┊  ┊    const { state } = navigation;
-┊33┊  ┊    return {
+┊  ┊51┊    const { state, navigate } = navigation;
+┊  ┊52┊
+┊  ┊53┊    const goToGroupDetails = navigate.bind(this, 'GroupDetails', {
+┊  ┊54┊      id: state.params.groupId,
 ┊34┊55┊      title: state.params.title,
+┊  ┊56┊    });
+┊  ┊57┊
+┊  ┊58┊    return {
+┊  ┊59┊      headerTitle: (
+┊  ┊60┊        <TouchableOpacity
+┊  ┊61┊          style={styles.titleWrapper}
+┊  ┊62┊          onPress={goToGroupDetails}
+┊  ┊63┊        >
+┊  ┊64┊          <View style={styles.title}>
+┊  ┊65┊            <Image
+┊  ┊66┊              style={styles.titleImage}
+┊  ┊67┊              source={{ uri: 'https://reactjs.org/logo-og.png' }}
+┊  ┊68┊            />
+┊  ┊69┊            <Text>{state.params.title}</Text>
+┊  ┊70┊          </View>
+┊  ┊71┊        </TouchableOpacity>
+┊  ┊72┊      ),
 ┊35┊73┊    };
 ┊36┊74┊  };
 ┊37┊75┊
@@ -125,6 +163,7 @@
 ┊125┊163┊Messages.propTypes = {
 ┊126┊164┊  createMessage: PropTypes.func,
 ┊127┊165┊  navigation: PropTypes.shape({
+┊   ┊166┊    navigate: PropTypes.func,
 ┊128┊167┊    state: PropTypes.shape({
 ┊129┊168┊      params: PropTypes.shape({
 ┊130┊169┊        groupId: PropTypes.number,
Added client/src/screens/new-group.screen.js
@@ -0,0 +1,320 @@
+┊   ┊  1┊import { _ } from 'lodash';
+┊   ┊  2┊import React, { Component } from 'react';
+┊   ┊  3┊import PropTypes from 'prop-types';
+┊   ┊  4┊import {
+┊   ┊  5┊  ActivityIndicator,
+┊   ┊  6┊  Button,
+┊   ┊  7┊  Image,
+┊   ┊  8┊  StyleSheet,
+┊   ┊  9┊  Text,
+┊   ┊ 10┊  View,
+┊   ┊ 11┊} from 'react-native';
+┊   ┊ 12┊import { graphql, compose } from 'react-apollo';
+┊   ┊ 13┊import AlphabetListView from 'react-native-alpha-listview';
+┊   ┊ 14┊import update from 'immutability-helper';
+┊   ┊ 15┊import Icon from 'react-native-vector-icons/FontAwesome';
+┊   ┊ 16┊
+┊   ┊ 17┊import SelectedUserList from '../components/selected-user-list.component';
+┊   ┊ 18┊import USER_QUERY from '../graphql/user.query';
+┊   ┊ 19┊
+┊   ┊ 20┊// eslint-disable-next-line
+┊   ┊ 21┊const sortObject = o => Object.keys(o).sort().reduce((r, k) => (r[k] = o[k], r), {});
+┊   ┊ 22┊
+┊   ┊ 23┊const styles = StyleSheet.create({
+┊   ┊ 24┊  container: {
+┊   ┊ 25┊    flex: 1,
+┊   ┊ 26┊    backgroundColor: 'white',
+┊   ┊ 27┊  },
+┊   ┊ 28┊  cellContainer: {
+┊   ┊ 29┊    alignItems: 'center',
+┊   ┊ 30┊    flex: 1,
+┊   ┊ 31┊    flexDirection: 'row',
+┊   ┊ 32┊    flexWrap: 'wrap',
+┊   ┊ 33┊    paddingHorizontal: 12,
+┊   ┊ 34┊    paddingVertical: 6,
+┊   ┊ 35┊  },
+┊   ┊ 36┊  cellImage: {
+┊   ┊ 37┊    width: 32,
+┊   ┊ 38┊    height: 32,
+┊   ┊ 39┊    borderRadius: 16,
+┊   ┊ 40┊  },
+┊   ┊ 41┊  cellLabel: {
+┊   ┊ 42┊    flex: 1,
+┊   ┊ 43┊    fontSize: 16,
+┊   ┊ 44┊    paddingHorizontal: 12,
+┊   ┊ 45┊    paddingVertical: 8,
+┊   ┊ 46┊  },
+┊   ┊ 47┊  selected: {
+┊   ┊ 48┊    flexDirection: 'row',
+┊   ┊ 49┊  },
+┊   ┊ 50┊  loading: {
+┊   ┊ 51┊    justifyContent: 'center',
+┊   ┊ 52┊    flex: 1,
+┊   ┊ 53┊  },
+┊   ┊ 54┊  navIcon: {
+┊   ┊ 55┊    color: 'blue',
+┊   ┊ 56┊    fontSize: 18,
+┊   ┊ 57┊    paddingTop: 2,
+┊   ┊ 58┊  },
+┊   ┊ 59┊  checkButtonContainer: {
+┊   ┊ 60┊    paddingRight: 12,
+┊   ┊ 61┊    paddingVertical: 6,
+┊   ┊ 62┊  },
+┊   ┊ 63┊  checkButton: {
+┊   ┊ 64┊    borderWidth: 1,
+┊   ┊ 65┊    borderColor: '#dbdbdb',
+┊   ┊ 66┊    padding: 4,
+┊   ┊ 67┊    height: 24,
+┊   ┊ 68┊    width: 24,
+┊   ┊ 69┊  },
+┊   ┊ 70┊  checkButtonIcon: {
+┊   ┊ 71┊    marginRight: -4, // default is 12
+┊   ┊ 72┊  },
+┊   ┊ 73┊});
+┊   ┊ 74┊
+┊   ┊ 75┊const SectionHeader = ({ title }) => {
+┊   ┊ 76┊  // inline styles used for brevity, use a stylesheet when possible
+┊   ┊ 77┊  const textStyle = {
+┊   ┊ 78┊    textAlign: 'center',
+┊   ┊ 79┊    color: '#fff',
+┊   ┊ 80┊    fontWeight: '700',
+┊   ┊ 81┊    fontSize: 16,
+┊   ┊ 82┊  };
+┊   ┊ 83┊
+┊   ┊ 84┊  const viewStyle = {
+┊   ┊ 85┊    backgroundColor: '#ccc',
+┊   ┊ 86┊  };
+┊   ┊ 87┊  return (
+┊   ┊ 88┊    <View style={viewStyle}>
+┊   ┊ 89┊      <Text style={textStyle}>{title}</Text>
+┊   ┊ 90┊    </View>
+┊   ┊ 91┊  );
+┊   ┊ 92┊};
+┊   ┊ 93┊SectionHeader.propTypes = {
+┊   ┊ 94┊  title: PropTypes.string,
+┊   ┊ 95┊};
+┊   ┊ 96┊
+┊   ┊ 97┊const SectionItem = ({ title }) => (
+┊   ┊ 98┊  <Text style={{ color: 'blue' }}>{title}</Text>
+┊   ┊ 99┊);
+┊   ┊100┊SectionItem.propTypes = {
+┊   ┊101┊  title: PropTypes.string,
+┊   ┊102┊};
+┊   ┊103┊
+┊   ┊104┊class Cell extends Component {
+┊   ┊105┊  constructor(props) {
+┊   ┊106┊    super(props);
+┊   ┊107┊    this.toggle = this.toggle.bind(this);
+┊   ┊108┊    this.state = {
+┊   ┊109┊      isSelected: props.isSelected(props.item),
+┊   ┊110┊    };
+┊   ┊111┊  }
+┊   ┊112┊
+┊   ┊113┊  componentWillReceiveProps(nextProps) {
+┊   ┊114┊    this.setState({
+┊   ┊115┊      isSelected: nextProps.isSelected(nextProps.item),
+┊   ┊116┊    });
+┊   ┊117┊  }
+┊   ┊118┊
+┊   ┊119┊  toggle() {
+┊   ┊120┊    this.props.toggle(this.props.item);
+┊   ┊121┊  }
+┊   ┊122┊
+┊   ┊123┊  render() {
+┊   ┊124┊    return (
+┊   ┊125┊      <View style={styles.cellContainer}>
+┊   ┊126┊        <Image
+┊   ┊127┊          style={styles.cellImage}
+┊   ┊128┊          source={{ uri: 'https://reactjs.org/logo-og.png' }}
+┊   ┊129┊        />
+┊   ┊130┊        <Text style={styles.cellLabel}>{this.props.item.username}</Text>
+┊   ┊131┊        <View style={styles.checkButtonContainer}>
+┊   ┊132┊          <Icon.Button
+┊   ┊133┊            backgroundColor={this.state.isSelected ? 'blue' : 'white'}
+┊   ┊134┊            borderRadius={12}
+┊   ┊135┊            color={'white'}
+┊   ┊136┊            iconStyle={styles.checkButtonIcon}
+┊   ┊137┊            name={'check'}
+┊   ┊138┊            onPress={this.toggle}
+┊   ┊139┊            size={16}
+┊   ┊140┊            style={styles.checkButton}
+┊   ┊141┊          />
+┊   ┊142┊        </View>
+┊   ┊143┊      </View>
+┊   ┊144┊    );
+┊   ┊145┊  }
+┊   ┊146┊}
+┊   ┊147┊Cell.propTypes = {
+┊   ┊148┊  isSelected: PropTypes.func,
+┊   ┊149┊  item: PropTypes.shape({
+┊   ┊150┊    username: PropTypes.string.isRequired,
+┊   ┊151┊  }).isRequired,
+┊   ┊152┊  toggle: PropTypes.func.isRequired,
+┊   ┊153┊};
+┊   ┊154┊
+┊   ┊155┊class NewGroup extends Component {
+┊   ┊156┊  static navigationOptions = ({ navigation }) => {
+┊   ┊157┊    const { state } = navigation;
+┊   ┊158┊    const isReady = state.params && state.params.mode === 'ready';
+┊   ┊159┊    return {
+┊   ┊160┊      title: 'New Group',
+┊   ┊161┊      headerRight: (
+┊   ┊162┊        isReady ? <Button
+┊   ┊163┊          title="Next"
+┊   ┊164┊          onPress={state.params.finalizeGroup}
+┊   ┊165┊        /> : undefined
+┊   ┊166┊      ),
+┊   ┊167┊    };
+┊   ┊168┊  };
+┊   ┊169┊
+┊   ┊170┊  constructor(props) {
+┊   ┊171┊    super(props);
+┊   ┊172┊
+┊   ┊173┊    let selected = [];
+┊   ┊174┊    if (this.props.navigation.state.params) {
+┊   ┊175┊      selected = this.props.navigation.state.params.selected;
+┊   ┊176┊    }
+┊   ┊177┊
+┊   ┊178┊    this.state = {
+┊   ┊179┊      selected: selected || [],
+┊   ┊180┊      friends: props.user ?
+┊   ┊181┊        _.groupBy(props.user.friends, friend => friend.username.charAt(0).toUpperCase()) : [],
+┊   ┊182┊    };
+┊   ┊183┊
+┊   ┊184┊    this.finalizeGroup = this.finalizeGroup.bind(this);
+┊   ┊185┊    this.isSelected = this.isSelected.bind(this);
+┊   ┊186┊    this.toggle = this.toggle.bind(this);
+┊   ┊187┊  }
+┊   ┊188┊
+┊   ┊189┊  componentDidMount() {
+┊   ┊190┊    this.refreshNavigation(this.state.selected);
+┊   ┊191┊  }
+┊   ┊192┊
+┊   ┊193┊  componentWillReceiveProps(nextProps) {
+┊   ┊194┊    const state = {};
+┊   ┊195┊    if (nextProps.user && nextProps.user.friends && nextProps.user !== this.props.user) {
+┊   ┊196┊      state.friends = sortObject(
+┊   ┊197┊        _.groupBy(nextProps.user.friends, friend => friend.username.charAt(0).toUpperCase()),
+┊   ┊198┊      );
+┊   ┊199┊    }
+┊   ┊200┊
+┊   ┊201┊    if (nextProps.selected) {
+┊   ┊202┊      Object.assign(state, {
+┊   ┊203┊        selected: nextProps.selected,
+┊   ┊204┊      });
+┊   ┊205┊    }
+┊   ┊206┊
+┊   ┊207┊    this.setState(state);
+┊   ┊208┊  }
+┊   ┊209┊
+┊   ┊210┊  componentWillUpdate(nextProps, nextState) {
+┊   ┊211┊    if (!!this.state.selected.length !== !!nextState.selected.length) {
+┊   ┊212┊      this.refreshNavigation(nextState.selected);
+┊   ┊213┊    }
+┊   ┊214┊  }
+┊   ┊215┊
+┊   ┊216┊  refreshNavigation(selected) {
+┊   ┊217┊    const { navigation } = this.props;
+┊   ┊218┊    navigation.setParams({
+┊   ┊219┊      mode: selected && selected.length ? 'ready' : undefined,
+┊   ┊220┊      finalizeGroup: this.finalizeGroup,
+┊   ┊221┊    });
+┊   ┊222┊  }
+┊   ┊223┊
+┊   ┊224┊  finalizeGroup() {
+┊   ┊225┊    const { navigate } = this.props.navigation;
+┊   ┊226┊    navigate('FinalizeGroup', {
+┊   ┊227┊      selected: this.state.selected,
+┊   ┊228┊      friendCount: this.props.user.friends.length,
+┊   ┊229┊      userId: this.props.user.id,
+┊   ┊230┊    });
+┊   ┊231┊  }
+┊   ┊232┊
+┊   ┊233┊  isSelected(user) {
+┊   ┊234┊    return ~this.state.selected.indexOf(user);
+┊   ┊235┊  }
+┊   ┊236┊
+┊   ┊237┊  toggle(user) {
+┊   ┊238┊    const index = this.state.selected.indexOf(user);
+┊   ┊239┊    if (~index) {
+┊   ┊240┊      const selected = update(this.state.selected, { $splice: [[index, 1]] });
+┊   ┊241┊
+┊   ┊242┊      return this.setState({
+┊   ┊243┊        selected,
+┊   ┊244┊      });
+┊   ┊245┊    }
+┊   ┊246┊
+┊   ┊247┊    const selected = [...this.state.selected, user];
+┊   ┊248┊
+┊   ┊249┊    return this.setState({
+┊   ┊250┊      selected,
+┊   ┊251┊    });
+┊   ┊252┊  }
+┊   ┊253┊
+┊   ┊254┊  render() {
+┊   ┊255┊    const { user, loading } = this.props;
+┊   ┊256┊
+┊   ┊257┊    // render loading placeholder while we fetch messages
+┊   ┊258┊    if (loading || !user) {
+┊   ┊259┊      return (
+┊   ┊260┊        <View style={[styles.loading, styles.container]}>
+┊   ┊261┊          <ActivityIndicator />
+┊   ┊262┊        </View>
+┊   ┊263┊      );
+┊   ┊264┊    }
+┊   ┊265┊
+┊   ┊266┊    return (
+┊   ┊267┊      <View style={styles.container}>
+┊   ┊268┊        {this.state.selected.length ? <View style={styles.selected}>
+┊   ┊269┊          <SelectedUserList
+┊   ┊270┊            data={this.state.selected}
+┊   ┊271┊            remove={this.toggle}
+┊   ┊272┊          />
+┊   ┊273┊        </View> : undefined}
+┊   ┊274┊        {_.keys(this.state.friends).length ? <AlphabetListView
+┊   ┊275┊          style={{ flex: 1 }}
+┊   ┊276┊          data={this.state.friends}
+┊   ┊277┊          cell={Cell}
+┊   ┊278┊          cellHeight={30}
+┊   ┊279┊          cellProps={{
+┊   ┊280┊            isSelected: this.isSelected,
+┊   ┊281┊            toggle: this.toggle,
+┊   ┊282┊          }}
+┊   ┊283┊          sectionListItem={SectionItem}
+┊   ┊284┊          sectionHeader={SectionHeader}
+┊   ┊285┊          sectionHeaderHeight={22.5}
+┊   ┊286┊        /> : undefined}
+┊   ┊287┊      </View>
+┊   ┊288┊    );
+┊   ┊289┊  }
+┊   ┊290┊}
+┊   ┊291┊
+┊   ┊292┊NewGroup.propTypes = {
+┊   ┊293┊  loading: PropTypes.bool.isRequired,
+┊   ┊294┊  navigation: PropTypes.shape({
+┊   ┊295┊    navigate: PropTypes.func,
+┊   ┊296┊    setParams: PropTypes.func,
+┊   ┊297┊    state: PropTypes.shape({
+┊   ┊298┊      params: PropTypes.object,
+┊   ┊299┊    }),
+┊   ┊300┊  }),
+┊   ┊301┊  user: PropTypes.shape({
+┊   ┊302┊    id: PropTypes.number,
+┊   ┊303┊    friends: PropTypes.arrayOf(PropTypes.shape({
+┊   ┊304┊      id: PropTypes.number,
+┊   ┊305┊      username: PropTypes.string,
+┊   ┊306┊    })),
+┊   ┊307┊  }),
+┊   ┊308┊  selected: PropTypes.arrayOf(PropTypes.object),
+┊   ┊309┊};
+┊   ┊310┊
+┊   ┊311┊const userQuery = graphql(USER_QUERY, {
+┊   ┊312┊  options: (ownProps) => ({ variables: { id: 1 } }), // fake for now
+┊   ┊313┊  props: ({ data: { loading, user } }) => ({
+┊   ┊314┊    loading, user,
+┊   ┊315┊  }),
+┊   ┊316┊});
+┊   ┊317┊
+┊   ┊318┊export default compose(
+┊   ┊319┊  userQuery,
+┊   ┊320┊)(NewGroup);
Changed server/data/resolvers.js
@@ -26,6 +26,44 @@
 ┊26┊26┊        groupId,
 ┊27┊27┊      });
 ┊28┊28┊    },
+┊  ┊29┊    createGroup(_, { name, userIds, userId }) {
+┊  ┊30┊      return User.findOne({ where: { id: userId } })
+┊  ┊31┊        .then(user => user.getFriends({ where: { id: { $in: userIds } } })
+┊  ┊32┊          .then(friends => Group.create({
+┊  ┊33┊            name,
+┊  ┊34┊            users: [user, ...friends],
+┊  ┊35┊          })
+┊  ┊36┊            .then(group => group.addUsers([user, ...friends])
+┊  ┊37┊              .then(() => group),
+┊  ┊38┊            ),
+┊  ┊39┊          ),
+┊  ┊40┊        );
+┊  ┊41┊    },
+┊  ┊42┊    deleteGroup(_, { id }) {
+┊  ┊43┊      return Group.find({ where: id })
+┊  ┊44┊        .then(group => group.getUsers()
+┊  ┊45┊          .then(users => group.removeUsers(users))
+┊  ┊46┊          .then(() => Message.destroy({ where: { groupId: group.id } }))
+┊  ┊47┊          .then(() => group.destroy()),
+┊  ┊48┊        );
+┊  ┊49┊    },
+┊  ┊50┊    leaveGroup(_, { id, userId }) {
+┊  ┊51┊      return Group.findOne({ where: { id } })
+┊  ┊52┊        .then(group => group.removeUser(userId)
+┊  ┊53┊          .then(() => group.getUsers())
+┊  ┊54┊          .then((users) => {
+┊  ┊55┊            // if the last user is leaving, remove the group
+┊  ┊56┊            if (!users.length) {
+┊  ┊57┊              group.destroy();
+┊  ┊58┊            }
+┊  ┊59┊            return { id };
+┊  ┊60┊          }),
+┊  ┊61┊        );
+┊  ┊62┊    },
+┊  ┊63┊    updateGroup(_, { id, name }) {
+┊  ┊64┊      return Group.findOne({ where: { id } })
+┊  ┊65┊        .then(group => group.update({ name }));
+┊  ┊66┊    },
 ┊29┊67┊  },
 ┊30┊68┊  Group: {
 ┊31┊69┊    users(group) {
Changed server/data/schema.js
@@ -49,6 +49,10 @@
 ┊49┊49┊    createMessage(
 ┊50┊50┊      text: String!, userId: Int!, groupId: Int!
 ┊51┊51┊    ): Message
+┊  ┊52┊    createGroup(name: String!, userIds: [Int], userId: Int!): Group
+┊  ┊53┊    deleteGroup(id: Int!): Group
+┊  ┊54┊    leaveGroup(id: Int!, userId: Int!): Group # let user leave group
+┊  ┊55┊    updateGroup(id: Int!, name: String): Group
 ┊52┊56┊  }
 ┊53┊57┊  
 ┊54┊58┊  schema {
< Previous Step Next Step >