diff --git a/assets/css/_game_info.scss b/assets/css/_game_info.scss
index 746bb80..5cb2e20 100644
--- a/assets/css/_game_info.scss
+++ b/assets/css/_game_info.scss
@@ -1,3 +1,29 @@
.game-info {
grid-area: game-info;
+
+ .offline,
+ .viewing {
+ &:before {
+ border-radius: 50%;
+ content: "";
+ display: inline-block;
+ height: 0.75rem;
+ margin-right: 0.25rem;
+ width: 0.75rem;
+ }
+ }
+
+ .offline {
+ opacity: 0.4;
+
+ &:before {
+ background-color: #cc3333;
+ }
+ }
+
+ .viewing {
+ &:before {
+ background-color: #66cc33;
+ }
+ }
}
diff --git a/assets/js/app.js b/assets/js/app.js
index 32a8f30..ee065d7 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -33,6 +33,10 @@ class App extends React.Component {
this.channel = new Channel(store, gameId);
}
+ componentWillUnmount() {
+ this.channel.leave();
+ }
+
get moves() {
const { store } = this.props;
return store.getState().moves;
diff --git a/assets/js/components/game-info.js b/assets/js/components/game-info.js
index 904028f..c83da11 100644
--- a/assets/js/components/game-info.js
+++ b/assets/js/components/game-info.js
@@ -4,7 +4,11 @@ import { connect } from "react-redux";
const GameInfo = (props) => {
return (
-
Playing {props.opponent}
+
+ Playing {props.opponent} -
+ {props.opponentStatus}
+
+
);
};
@@ -12,6 +16,7 @@ const GameInfo = (props) => {
const mapStateToProps = (state) => {
return {
opponent: state.opponent,
+ opponentStatus: state.opponentStatus,
};
};
diff --git a/assets/js/reducers/chess-board.js b/assets/js/reducers/chess-board.js
index 298c239..0b4f130 100644
--- a/assets/js/reducers/chess-board.js
+++ b/assets/js/reducers/chess-board.js
@@ -4,14 +4,17 @@ import defaultState from "../store/default-state";
const chessBoardReducer = (state = defaultState, action) => {
switch (action.type) {
- case "SET_PLAYER":
+ case "SET_USER_ID":
return Immutable.fromJS(state)
- .set("player", action.player)
+ .set("userId", action.user_id)
.toJS();
- case "SET_OPPONENT":
+ case "SET_PLAYERS":
return Immutable.fromJS(state)
+ .set("player", action.player)
+ .set("playerId", action.player_id)
.set("opponent", action.opponent)
+ .set("opponentId", action.opponent_id)
.toJS();
case "SET_GAME":
@@ -40,6 +43,11 @@ const chessBoardReducer = (state = defaultState, action) => {
.set("availableMoves", [])
.toJS();
+ case "SET_OPPONENT_STATUS":
+ return Immutable.fromJS(state)
+ .set("opponentStatus", action.opponentStatus)
+ .toJS();
+
default:
return state;
}
diff --git a/assets/js/services/channel.js b/assets/js/services/channel.js
index 6f30a0c..b21b98d 100644
--- a/assets/js/services/channel.js
+++ b/assets/js/services/channel.js
@@ -1,10 +1,19 @@
-import socket from "../socket";
-import { setPlayer, setOpponent, setGame, setAvailableMoves } from "../store/actions";
+import _ from "lodash";
+import socket from "./socket";
+import { Presence } from "phoenix";
+import {
+ setUserId,
+ setPlayers,
+ setGame,
+ setAvailableMoves,
+ setOpponentStatus,
+} from "../store/actions";
class Channel {
constructor(store, gameId) {
this.store = store;
this.channel = socket.channel(`game:${gameId}`, {});
+ this.presences = {};
this.join();
this.subscribe();
@@ -17,13 +26,41 @@ class Channel {
});
}
+ leave() {
+ this.channel.leave();
+ }
+
subscribe() {
- this.channel.on("game:update", data => {
- if (data.player != undefined) {
- this.store.dispatch(setPlayer(data.player));
- this.store.dispatch(setOpponent(data.opponent));
- }
- this.store.dispatch(setGame(data));
+ this.channel.on("game:update", this.updateGame.bind(this));
+
+ this.channel.on("presence_state", data => {
+ this.presences = Presence.syncState(this.presences, data);
+ this.setOpponentStatus();
+ });
+
+ this.channel.on("presence_diff", data => {
+ this.presences = Presence.syncDiff(this.presences, data);
+ this.setOpponentStatus();
+ });
+ }
+
+ updateGame(data) {
+ if (data.player != undefined) {
+ this.store.dispatch(setUserId(data.user_id));
+ this.store.dispatch(setPlayers(data));
+ }
+ this.store.dispatch(setGame(data));
+ }
+
+ setOpponentStatus() {
+ this.store.dispatch(setOpponentStatus(
+ this.opponentOnline() ? "viewing" : "offline"
+ ));
+ }
+
+ opponentOnline() {
+ return _.find(this.presences, (value, id) => {
+ return parseInt(id) == this.store.getState().opponentId;
});
}
diff --git a/assets/js/socket.js b/assets/js/services/socket.js
similarity index 100%
rename from assets/js/socket.js
rename to assets/js/services/socket.js
diff --git a/assets/js/store/actions.js b/assets/js/store/actions.js
index 9d6ee7b..152e9f7 100644
--- a/assets/js/store/actions.js
+++ b/assets/js/store/actions.js
@@ -1,21 +1,25 @@
-const SET_PLAYER = "SET_PLAYER";
-const SET_OPPONENT = "SET_OPPONENT";
+const SET_USER_ID = "SET_USER_ID";
+const SET_PLAYERS = "SET_PLAYERS";
const SET_GAME = "SET_GAME";
const SET_AVAILABLE_MOVES = "SET_AVAILABLE_MOVES";
const SET_GAME_ID = "SET_GAME_ID";
const SELECT_PIECE = "SELECT_PIECE";
+const SET_OPPONENT_STATUS = "SET_OPPONENT_STATUS";
-export const setPlayer = (player) => {
+export const setUserId = (user_id) => {
return {
- type: SET_PLAYER,
- player,
+ type: SET_USER_ID,
+ user_id,
};
};
-export const setOpponent = (opponent) => {
+export const setPlayers = (data) => {
return {
- type: SET_OPPONENT,
- opponent,
+ type: SET_PLAYERS,
+ player: data.player,
+ player_id: data.player_id,
+ opponent: data.opponent,
+ opponent_id: data.opponent_id,
};
};
@@ -49,3 +53,10 @@ export const selectPiece = (coords) => {
coords,
};
};
+
+export const setOpponentStatus = (opponentStatus) => {
+ return {
+ type: SET_OPPONENT_STATUS,
+ opponentStatus,
+ };
+};
diff --git a/assets/js/store/default-state.js b/assets/js/store/default-state.js
index 9fc5c61..801d13c 100644
--- a/assets/js/store/default-state.js
+++ b/assets/js/store/default-state.js
@@ -1,11 +1,16 @@
const defaultState = {
selectedSquare: null,
+ playerId: null,
+ opponentId: null,
+
player: null,
opponent: null,
turn: null,
state: null,
+ opponentStatus: "offline",
+
availableMoves: [],
moves: [],
diff --git a/config/config.exs b/config/config.exs
index ed71f8c..372fe09 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -14,7 +14,7 @@ config :chess, ChessWeb.Endpoint,
url: [host: "localhost"],
secret_key_base: "iiTDTKorCWTFoeBgAkr35XZp22cNIM2RsmnHiHdzKAuSHXUGXx42z7lawAwiu1B1",
render_errors: [view: ChessWeb.ErrorView, accepts: ~w(html json)],
- pubsub: [name: ChessWeb.PubSub,
+ pubsub: [name: Chess.PubSub,
adapter: Phoenix.PubSub.PG2]
# Configures Elixir's Logger
diff --git a/lib/chess.ex b/lib/chess.ex
index 923ec2f..97bb1f1 100644
--- a/lib/chess.ex
+++ b/lib/chess.ex
@@ -16,6 +16,7 @@ defmodule Chess do
supervisor(ChessWeb.Endpoint, []),
# Start your own worker by calling: Chess.Worker.start_link(arg1, arg2, arg3)
# worker(Chess.Worker, [arg1, arg2, arg3]),
+ supervisor(ChessWeb.Presence, []),
]
# See http://elixir-lang.org/docs/stable/elixir/Supervisor.html
diff --git a/lib/chess_web/channels/game_channel.ex b/lib/chess_web/channels/game_channel.ex
index 85edd1f..aab0e28 100644
--- a/lib/chess_web/channels/game_channel.ex
+++ b/lib/chess_web/channels/game_channel.ex
@@ -9,6 +9,7 @@ defmodule ChessWeb.GameChannel do
alias Chess.MoveList
alias Chess.Moves
alias Chess.Repo.Queries
+ alias ChessWeb.Presence
def join("game:" <> game_id, _params, socket) do
send(self(), {:after_join, game_id})
@@ -18,12 +19,14 @@ defmodule ChessWeb.GameChannel do
def handle_info({:after_join, game_id}, socket) do
game =
- socket.assigns.current_user_id
+ socket.assigns.user_id
|> Queries.game_for_info(game_id)
payload = %{
- player: player(game, socket.assigns.current_user_id),
- opponent: opponent(game, socket.assigns.current_user_id).name,
+ player_id: socket.assigns.user_id,
+ opponent_id: opponent(game, socket.assigns.user_id).id,
+ player: player(game, socket.assigns.user_id),
+ opponent: opponent(game, socket.assigns.user_id).name,
board: Board.transform(game.board),
turn: game.turn,
state: game.state,
@@ -33,13 +36,15 @@ defmodule ChessWeb.GameChannel do
socket
|> push("game:update", payload)
+ track_presence(socket)
+
{:noreply, socket}
end
def handle_in("game:move", params, socket) do
move_params = convert_params(params)
- socket.assigns.current_user_id
+ socket.assigns.user_id
|> Queries.game_with_moves(socket.assigns.game_id)
|> Moves.make_move(move_params)
|> case do
@@ -60,7 +65,7 @@ defmodule ChessWeb.GameChannel do
socket
) do
game =
- socket.assigns.current_user_id
+ socket.assigns.user_id
|> Queries.game_with_moves(socket.assigns.game_id)
moves = Moves.available(game.board, {
@@ -75,6 +80,16 @@ defmodule ChessWeb.GameChannel do
{:reply, {:ok, reply}, socket}
end
+ def track_presence(socket) do
+ {:ok, _} = Presence.track(socket, socket.assigns.user_id, %{
+ user_id: socket.assigns.user_id,
+ online_at: inspect(System.system_time(:seconds))
+ })
+
+ socket
+ |> push("presence_state", Presence.list(socket))
+ end
+
def convert_params(%{"from" => from, "to" => to}) do
%{
"from" => Enum.map(from, &(String.to_integer(&1))),
@@ -84,7 +99,7 @@ defmodule ChessWeb.GameChannel do
def send_update(socket) do
game =
- socket.assigns.current_user_id
+ socket.assigns.user_id
|> Queries.game_with_moves(socket.assigns.game_id)
payload = %{
diff --git a/lib/chess_web/channels/presence.ex b/lib/chess_web/channels/presence.ex
new file mode 100644
index 0000000..8de3625
--- /dev/null
+++ b/lib/chess_web/channels/presence.ex
@@ -0,0 +1,73 @@
+defmodule ChessWeb.Presence do
+ @moduledoc """
+ Provides presence tracking to channels and processes.
+
+ See the [`Phoenix.Presence`](http://hexdocs.pm/phoenix/Phoenix.Presence.html)
+ docs for more details.
+
+ ## Usage
+
+ Presences can be tracked in your channel after joining:
+
+ defmodule Chess.MyChannel do
+ use ChessWeb, :channel
+ alias Chess.Presence
+
+ def join("some:topic", _params, socket) do
+ send(self, :after_join)
+ {:ok, assign(socket, :user_id, ...)}
+ end
+
+ def handle_info(:after_join, socket) do
+ push socket, "presence_state", Presence.list(socket)
+ {:ok, _} = Presence.track(socket, socket.assigns.user_id, %{
+ online_at: inspect(System.system_time(:seconds))
+ })
+ {:noreply, socket}
+ end
+ end
+
+ In the example above, `Presence.track` is used to register this
+ channel's process as a presence for the socket's user ID, with
+ a map of metadata. Next, the current presence list for
+ the socket's topic is pushed to the client as a `"presence_state"` event.
+
+ Finally, a diff of presence join and leave events will be sent to the
+ client as they happen in real-time with the "presence_diff" event.
+ See `Phoenix.Presence.list/2` for details on the presence datastructure.
+
+ ## Fetching Presence Information
+
+ The `fetch/2` callback is triggered when using `list/1`
+ and serves as a mechanism to fetch presence information a single time,
+ before broadcasting the information to all channel subscribers.
+ This prevents N query problems and gives you a single place to group
+ isolated data fetching to extend presence metadata.
+
+ The function receives a topic and map of presences and must return a
+ map of data matching the Presence datastructure:
+
+ %{"123" => %{metas: [%{status: "away", phx_ref: ...}],
+ "456" => %{metas: [%{status: "online", phx_ref: ...}]}
+
+ The `:metas` key must be kept, but you can extend the map of information
+ to include any additional information. For example:
+
+ def fetch(_topic, entries) do
+ users = entries |> Map.keys() |> Accounts.get_users_map(entries)
+ # => %{"123" => %{name: "User 123"}, "456" => %{name: nil}}
+
+ for {key, %{metas: metas}} <- entries, into: %{} do
+ {key, %{metas: metas, user: users[key]}}
+ end
+ end
+
+ The function above fetches all users from the database who
+ have registered presences for the given topic. The fetched
+ information is then extended with a `:user` key of the user's
+ information, while maintaining the required `:metas` field from the
+ original presence data.
+ """
+ use Phoenix.Presence, otp_app: :chess,
+ pubsub_server: Chess.PubSub
+end
diff --git a/lib/chess_web/channels/user_socket.ex b/lib/chess_web/channels/user_socket.ex
index 84a5cb7..6d7c3cd 100644
--- a/lib/chess_web/channels/user_socket.ex
+++ b/lib/chess_web/channels/user_socket.ex
@@ -24,7 +24,7 @@ defmodule ChessWeb.UserSocket do
def connect(%{"token" => token}, socket) do
case Token.verify(socket, "game socket", token, max_age: 1_209_600) do
{:ok, user_id} ->
- {:ok, assign(socket, :current_user_id, user_id)}
+ {:ok, assign(socket, :user_id, user_id)}
{:error, _reason} ->
:error
end
diff --git a/mix.exs b/mix.exs
index 691769d..d2d7850 100644
--- a/mix.exs
+++ b/mix.exs
@@ -21,7 +21,7 @@ defmodule Chess.Mixfile do
def application do
[
mod: {Chess, []},
- extra_applications: [:logger]
+ extra_applications: [:logger],
]
end
diff --git a/test/chess_web/channels/user_socket_test.exs b/test/chess_web/channels/user_socket_test.exs
index 9cdde0a..4e70cc9 100644
--- a/test/chess_web/channels/user_socket_test.exs
+++ b/test/chess_web/channels/user_socket_test.exs
@@ -7,7 +7,7 @@ defmodule ChessWeb.UserSocketTest do
token = Phoenix.Token.sign(@endpoint, "game socket", 42)
assert {:ok, socket} = connect(UserSocket, %{"token" => token})
- assert socket.assigns.current_user_id == 42
+ assert socket.assigns.user_id == 42
end
test "cannot authenticate with invalid token" do