From 7280c98cc9981f01e6e44500514d3442ca4e756a Mon Sep 17 00:00:00 2001 From: Dan Barber Date: Sun, 26 Aug 2018 15:04:08 -0400 Subject: [PATCH] Show user status --- assets/css/_game_info.scss | 26 +++++++ assets/js/app.js | 4 ++ assets/js/components/game-info.js | 7 +- assets/js/reducers/chess-board.js | 14 +++- assets/js/services/channel.js | 53 +++++++++++--- assets/js/{ => services}/socket.js | 0 assets/js/store/actions.js | 27 +++++--- assets/js/store/default-state.js | 5 ++ config/config.exs | 2 +- lib/chess.ex | 1 + lib/chess_web/channels/game_channel.ex | 27 ++++++-- lib/chess_web/channels/presence.ex | 73 ++++++++++++++++++++ lib/chess_web/channels/user_socket.ex | 2 +- mix.exs | 2 +- test/chess_web/channels/user_socket_test.exs | 2 +- 15 files changed, 215 insertions(+), 30 deletions(-) rename assets/js/{ => services}/socket.js (100%) create mode 100644 lib/chess_web/channels/presence.ex 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