This post belongs to the Trello tribute with Phoenix Framework and React series.
- Intro and selected stack
- Phoenix Framework project setup
- The User model and JWT auth
- Front-end for sign up with React and Redux
- Database seeding and sign in controller
- Front-end authentication with React and Redux
- Sockets and channels
- Listing and creating boards
- Adding new board members
- Tracking connected board members
- Adding lists and cards
- Deploying our application on Heroku
When a connected member leaves the board's url, signs out or even closes his
browser we want to broadcast again this event to all connected users in the board channel
so his avatar gets semitransparent again, reflecting the user is no longer
viewing the board. Let's think about some ways we could achieve this and their drawbacks:
1. Managing the connected members list on the front-end in the **Redux** store. This can sound as a valid approach at first but it will only work for members which are already connected to the board channel. Recently connected users will not have that data on their application state.
2. Using the database to keep track of connected members. This could also be valid, but will force us to constantly be hitting the database to ask for connected members and update it whenever a members connects or leaves, not to mention mixing data with a very specific user behavior.
So where can we store this information so it's accessible to all users in a fast
and efficient way? Easy. In a... wait for it... long running stateful process.
### The GenServer behavior
Although *long running stateful process* might sound a bit intimidating at first,
it's a lot more easier to implement than we might expect, thanks to **Elixir**
and it's [GensServer][9100679b] behavior.
> A GenServer is a process as any other Elixir process and it can be used to keep
> state, execute code asynchronously and so on.
Imagine it as a small process running in our server with a map containing the list
of connected user ids per board. Something like this:
```elixir
%{
"1" => [1, 2, 3],
"2" => [4, 5]
}
```
Now imagine that this process ​had a public interface to init itself and update its state map, for adding or removing boards and connected users. Well, that's
basically a **GenServer** process, and I say *basically* because it will also have underlying
advantages like tracing, error reporting and supervision capabilities.
### The BoardChannel Monitor
So let's create our very basic version of this process which is going to keep track
of the list of board connected members:
```elixir
# /lib/phoenix_trello/board_channel/monitor.ex
defmodule PhoenixTrello.BoardChannel.Monitor do
use GenServer
#####
# Client API
def start_link(initial_state) do
GenServer.start_link(__MODULE__, initial_state, name: __MODULE__)
end
end
```
When working with **GenServer** we have to think both in the external client API
functions and the server implementation of them. The first we need to implement is the
`start_link` one, which will really start our **GenServer** passing the initial state,
in our case an empty map, as an argument among the module and the name of the server.
We want this process to start when our application starts too, so let's add it to
the children list of our application supervision tree:
```elixir
# /lib/phoenix_trello.ex
defmodule PhoenixTrello do
use Application
def start(_type, _args) do
import Supervisor.Spec, warn: false
children = [
# ...
worker(PhoenixTrello.BoardChannel.Monitor, [%{}]),
# ...
]
# ...
end
end
```
By doing this, every time our application starts it will automatically call the
`start_link` function we've just created passing the `%{}` empty map as initial state.
If the `Monitor` happened to break for any reason, the application
will also automatically restart it again with a new empty map. Cool, isn't it? Now
that we have setup everything let's beging with adding members to the `Monitor`'s state map.
### Handling joining members
For this we need to add both the client function and it's server callback handler:
```ruby
# /lib/phoenix_trello/board_channel/monitor.ex
defmodule PhoenixTrello.BoardChannel.Monitor do
use GenServer
#####
# Client API
# ...
def member_joined(board, member) do
GenServer.call(__MODULE__, {:member_joined, board, member})
end
#####
# Server callbacks
def handle_call({:member_joined, board, member}, _from, state) do
state = case Map.get(state, board) do
nil ->
state = state
|> Map.put(board, [member])
{:reply, [member], state}
members ->
state = state
|> Map.put(board, Enum.uniq([member | members]))
{:reply, Map.get(state, board), state}
end
end
end
```
When calling the `member_joined/2` function passing a board and a user, we will internally
make a call to the **GenServer** process with the message `{:member_joined, board, member}`.
Thus why we need to add a server callback handler for it. The [`handle_call/3`][8f8d7552]
callback function from `GenServer` receives the request message, the caller, and the
current state. So in our case we will try to get the board from the state, and add
the user to the list of users for it. In case we don't have that board yet, we'll add
it with a new list containing the joined user. As response we will return the user list belonging
to the board.
Having this done, where should we call the `member_joined` method? In the **BoardChannel** while
the user joins:
```elixir
# /web/channels/board_channel.ex
defmodule PhoenixTrello.BoardChannel do
use PhoenixTrello.Web, :channel
alias PhoenixTrello.{User, Board, UserBoard, List, Card, Comment, CardMember}
alias PhoenixTrello.BoardChannel.Monitor
def join("boards:" <> board_id, _params, socket) do
current_user = socket.assigns.current_user
board = get_current_board(socket, board_id)
connected_users = Monitor.user_joined(board_id, current_user.id)
send(self, {:after_join, connected_users})
{:ok, %{board: board}, assign(socket, :board, board)}
end
def handle_info({:after_join, connected_users}, socket) do
broadcast! socket, "user:joined", %{users: connected_users}
{:noreply, socket}
end
# ...
end
```
So when he joins we use the new `Monitor` to track him, and broadcast through the
socket the updated list of users currently in the board. Now we can handle this broadcast
in the front-end to update the application state with the new list of connected users:
```javascript
// /web/static/js/actions/current_board.js
import Constants from '../constants';
const Actions = {
// ...
connectToChannel: (socket, boardId) => {
return dispatch => {
const channel = socket.channel(`boards:${boardId}`);
// ...
channel.on('user:joined', (msg) => {
dispatch({
type: Constants.CURRENT_BOARD_CONNECTED_USERS,
users: msg.users,
});
});
};
}
}
```
The only thing left is to change the avatar's opacity depending on whether the board
member is listed in this array or not:
```javascript
// /web/static/js/components/boards/users.js
export default class BoardUsers extends React.Component {
_renderUsers() {
return this.props.users.map((user) => {
const index = this.props.connectedUsers.findIndex((cu) => {
return cu.id === user.id;
});
const classes = classnames({ connected: index != -1 });
return (