Let’s Build |> a Slack Clone with Elixir, Phoenix, and React (part 6— Phoenix Presence)

Live Demo — GitHub Repo
Part 1 — Part 2 — Part 3 — Part 4 — Part 5 — Part 6 — Part 7

We left off at the end of part 5 with a fully functioning chat room. But now we’re going to add a Sidebar with a list of active users in the room. Phoenix Presence makes this very straightforward.

Start by running the Presence generator

mix phoenix.gen.presence

Than generator will print output telling you to add the module to you supervision tree

/sling/api/lib/sling.ex

After editing that file you will need to restart you Phoenix server

Now we edit the RoomChannel to track the presence of users in a specific room

sling/api/web/channels/room_channel.ex

We just added one line in the join function, send self(), :after_join. Then the new handle_info function uses the Presence module to track the socket with a key equal to socket’s current_user id, and a meta object. In the meta object, we can put any sort of information, such as the type of device or time online. Here I’m just creating a :user key with a value equal to the json representation of the user that we created in the UserView. Then the push function sends a “presence_state” event to the frontend which we can catch to sync our list of users.

These presence actions will be right alongside the other channel actions we created in the actions/room.js file

sling/web/src/actions/room.js

First we initialize an empty presences object, and then update that on the ‘presence_state’ event we added in the room channel, and also for a ‘presence_diff’ event, which Phoenix emits whenever a user disconnects from a channel.

During each event, we will call our syncPresentUsers() function. The Presence class in the Phoenix javascript library has a list function, which takes the presences object as it’s first argument, and a function that returns a value that we want to list each present object by. Chris McCord has a helpful blog post which describe this better. In my case, I’m writing that function inline which selects the :user meta key we defined, so we can map over each present user and push it into an array of present users. This array is then dispatched to our room reducer.

sling/web/src/reducers/room.js

Now we can get the list of current users from the redux store, and pass it in to a new RoomSidebar component in our Room container. We will also add the currentUser as a prop so we can display their username in the RoomSidebar.

sling/web/src/containers/Room/index.js

And here is the new RoomSidebar component. It’s basically a bunch of styles, and maps over each presentUser and displays the username.

sling/web/src/components/RoomSidebar/index.js

If you can believe it, that’s it to show a list of present users. Log in with a few different incognito browser windows and see the list update as you come and go.

Git commit with Presence working

Our Slack clone is pretty close to complete. But to make the app more user friendly we need to implement pagination, and have the messages view scroll correctly when new messages are created.

Scrolling Pagination

Right now when messages are created, the new message gets broadcast through the channel and added to the array of messages for all users. But for pagination, we want one user to be able to load older messages without this broadcasting to all users.

So to solve this, we will implement a new API endpoint. We will fetch data from there when a user scrolls to the top of the message list, and prepend the messages to their message array.

Create a new message_controller.ex, which will have a similar query to room_channel.ex:

sling/api/web/controllers/api/message_controller.ex

The key difference here, is that we expect a last_seen_id parameter. Because the list of messages is constantly streaming, we will pass in the oldest message id we currently have in the frontend, and then fetch the next set of messages that were created before that id.

This new index action will also need a corresponding “index.json” view in MessageView

sling/api/web/views/message_view.ex

We also need to add this new route to router.ex, nesting it inside the rooms routes

sling/api/web/router.ex

Let’s switch over to the frontend, and create the action that will load older messages

sling/web/src/actions/room.js

We are dispatching a REQUEST, SUCCESS, and FAILURE cases so we can update the UI accordingly.

Now we can update our room reducer to keep track of paginated messages:

sling/web/src/reducers/room.js

Same as in our initial response, we need to reverse() the messages to get them in the correct order for our UI. And we just toggle a loadingOlderMessages value to track our loading state.

I’m going to jump ahead and update the MessageList component with the functionality it will need to track scrolling and then discuss it:

sling/web/src/components/MessageList/index.js

First of all, in our constructor we are debouncing handleScroll(), so this only gets called after a 200 ms delay. And we attach an event listener to the window for this function when the component mounts/unmounts.

Then handleScroll() will call a loadMore() function that will be dispatched in the Room container, only if there are more messages to be loaded and if the user has scrolled within 20px of the top of the container. In a moment, we will implement the moreMessages and loadMore props in the Room container.

In the componentWillReceiveProps lifecycle method, we call a new function, maybeScrollToBottom() when our length of messages changes — basically, whenever a new message is received.

maybeScrollToBottom() will only scroll to the bottom if the user’s current scroll position in the message list is within 50px of the bottom. This way if they have scrolled up to view older messages, we won’t pull their screen back down when a new message is received. But if they are viewing the bottom of the message list, their view will scroll down to show newly received messages.

The scrollToBottom() function is wrapped in a setTimeout() function because without it, it will be off by the 43px height of the most recent message — just a little hack.

Let’s jump back to the Room container to implement what’s left:

sling/web/src/containers/Room/index.js

The important parts here, are that we added a handleLoadMore() function, which catches the MessageLists’s onLoadMore prop. It will take the oldest message, (the first item in the message list — this.props.messages[0].id), and use that as the last_seen_id parameter.

The MessageList will know if there are any more messages to be loaded based on if the the pagination’s total_pages is greater than the current page_number.

In handleMessageCreate(), we’re breaking a React convention by calling the scrollToBottom() function that is inside the child MessageList component. I’m making an excuse in this case because it’s a concise solution to ensure the user that writes a new message will be scrolled to the bottom of the page if they had previously scrolled up in the view — otherwise it might not be evident that their message was successfully sent.

I’m happy with the functionality of our Sling app at this point, so let’s call it quits for this section, and deploy this app to Heroku in part 7.

Git commit with scrolling pagination working

Read part 7 or view the live demo