diff --git a/lib/kolab_chat.ex b/lib/kolab_chat.ex index 41af2d8..8259186 100644 --- a/lib/kolab_chat.ex +++ b/lib/kolab_chat.ex @@ -1,31 +1,33 @@ defmodule KolabChat do use Application # See http://elixir-lang.org/docs/stable/elixir/Application.html # for more information on OTP Applications def start(_type, _args) do import Supervisor.Spec # Define workers and child supervisors to be supervised children = [ # Start the Ecto repository supervisor(KolabChat.Repo, []), # Start the endpoint when the application starts supervisor(KolabChat.Endpoint, []), + # Start phoenix presence module + supervisor(KolabChat.Presence, []), # Start your own worker by calling: KolabChat.Worker.start_link(arg1, arg2, arg3) # worker(KolabChat.Worker, [arg1, arg2, arg3]), ] # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html # for other strategies and supported options opts = [strategy: :one_for_one, name: KolabChat.Supervisor] Supervisor.start_link(children, opts) end # Tell Phoenix to update the endpoint configuration # whenever the application is updated. def config_change(changed, _new, removed) do KolabChat.Endpoint.config_change(changed, removed) :ok end end diff --git a/web/channels/presence.ex b/web/channels/presence.ex new file mode 100644 index 0000000..3492c97 --- /dev/null +++ b/web/channels/presence.ex @@ -0,0 +1,77 @@ +defmodule KolabChat.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 KolabChat.MyChannel do + use KolabChat.Web, :channel + alias KolabChat.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 + {:ok, _} = Presence.track(socket, socket.assigns.user_id, %{ + online_at: inspect(System.system_time(:seconds)) + }) + push socket, "presence_state", Presence.list(socket) + {: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 + query = + from u in User, + where: u.id in ^Map.keys(entries), + select: {u.id, u} + + users = query |> Repo.all |> Enum.into(%{}) + + 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: :kolab_chat, + pubsub_server: KolabChat.PubSub +end diff --git a/web/channels/system_channel.ex b/web/channels/system_channel.ex new file mode 100644 index 0000000..b9b598b --- /dev/null +++ b/web/channels/system_channel.ex @@ -0,0 +1,38 @@ +defmodule KolabChat.SystemChannel do + use KolabChat.Web, :channel + + alias KolabChat.Presence + + def join("system", _, socket) do + Process.flag(:trap_exit, true) + :timer.send_interval(10000, :ping) + send self(), :after_join + + {:ok, socket} + end + + def handle_info(:after_join, socket) do + Presence.track(socket, socket.assigns.user.username, %{ + status: "online" + }) + + push socket, "info", %{user: socket.assigns.user.username} + push socket, "presence_state", Presence.list(socket) + + {:noreply, socket} + end + + def handle_info(:ping, socket) do + push socket, "new:msg", %{user: "SYSTEM", body: "ping"} + + {:noreply, socket} + end + + def handle_in("set-status", %{"status" => status}, socket) do + {:ok, _} = Presence.update(socket, socket.assigns.user.username, %{ + status: status + }) + + {:noreply, socket} + end +end diff --git a/web/channels/user_socket.ex b/web/channels/user_socket.ex index 3cc5e71..6daa9ee 100644 --- a/web/channels/user_socket.ex +++ b/web/channels/user_socket.ex @@ -1,41 +1,42 @@ defmodule KolabChat.UserSocket do use Phoenix.Socket alias KolabChat.Repo alias KolabChat.User ## Channels - # channel "room:*", KolabChat.RoomChannel + #channel "room:*", KolabChat.RoomChannel + channel "system", KolabChat.SystemChannel ## Transports transport :websocket, Phoenix.Transports.WebSocket - # transport :longpoll, Phoenix.Transports.LongPoll + transport :longpoll, Phoenix.Transports.LongPoll # Socket params are passed from the client and can # be used to verify and authenticate a user. After # verification, you can put default assigns into # the socket that will be set for all channels, ie # {:ok, assign(socket, :user_id, verified_user_id)} # To deny connection, return `:error`. def connect(%{"token" => token}, socket) do case Phoenix.Token.verify(socket, "user", token, max_age: 86400) do {:ok, user_id} -> socket = assign(socket, :user, Repo.get!(User, user_id)) {:ok, socket} {:error, _} -> :error end end # Socket id's are topics that allow you to identify all sockets for a given user: # # def id(socket), do: "users_socket:#{socket.assigns.user_id}" # # Would allow you to broadcast a "disconnect" event and terminate # all active sockets and channels for a given user: # # KolabChat.Endpoint.broadcast("users_socket:#{user.id}", "disconnect", %{}) # # Returning `nil` makes this socket anonymous. def id(_socket), do: nil end diff --git a/web/static/css/app.css b/web/static/css/app.css index b95ba2a..315be25 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -1,13 +1,21 @@ /* This file is for your main application css. */ header form { float: right; text-align: right; margin-top: 15px; margin-right: 55px; } header form input { display: block; margin-bottom: 3px; } + +ul.userlist li { + text-align: left; +} + +ul.userlist li small { + color: #666; +} diff --git a/web/static/css/widgets.css b/web/static/css/widgets.css new file mode 100644 index 0000000..4a57d10 --- /dev/null +++ b/web/static/css/widgets.css @@ -0,0 +1,13 @@ +/* Style for chat application widgets */ + +.status-online .glyphicon { + color: green; +} + +.status-away .glyphicon { + color: grey; +} + +.status-busy .glyphicon { + color: red; +} diff --git a/web/static/js/api.js b/web/static/js/api.js index e0732ad..ee4d001 100644 --- a/web/static/js/api.js +++ b/web/static/js/api.js @@ -1,39 +1,129 @@ -import {Socket} from "phoenix" +import {Socket, LongPoll, Presence} from "phoenix" +import UserListWidget from "./widgets/userlist" +import UserStatusWidget from "./widgets/userstatus" class KolabChat { + /** + * Configuration parameters: + * - userListElement: Id of HTML element where to put userslist widget + * - userStatusElement: Id of HTML element where to put users status widget + */ constructor(config) { - this.config = config || {}; + this.config = config || {} } + /** + * Initialize WebSocket communication + */ init(config) { if (config) - this.config = KolabChat.extend(this.config, config); + this.config = KolabChat.extend(this.config, config) - var params = { - token: this.config.token, - logger: ((kind, msg, data) => { console.log(`${kind}: ${msg}`, data) }) - }; + this.initWidgets() - // FIXME: for some reason full URL did not work here - // We may need it for integration with external systems + // TODO: for integration with external systems we'll use configurable full wss:// url - this.socket = new Socket("/socket", {params: params}); - this.socket.connect(); + this.socket = new Socket("/socket", { + params: {token: this.config.token}, + logger: ((kind, msg, data) => { console.log(`${kind}: ${msg}`, data) }), + }) - this.socket.onOpen(e => console.log("OPEN", e)) - this.socket.onError(e => console.log("ERROR", e)) - this.socket.onClose(e => console.log("CLOSE", e)) + this.socket.onOpen(e => { + // when connected start using 'system' channel + // for users' presence + this.initPresence() + }) + + this.socket.connect() + } + + /** + * Initializes configured UI widgets + */ + initWidgets() + { + if (this.config.userListElement) { + this.userListWidget = new UserListWidget(this.config.userListElement) + } + + if (this.config.userStatusElement) { + let config = {statusChange: status => { this.setStatus(status) }} + this.userStatusWidget = new UserStatusWidget(this.config.userStatusElement, config) + } + } + + /** + * Initialize user presence + * Create users list and status widgets + */ + initPresence() + { + this.system = this.socket.channel("system") + + this.system.on("info", info => { + this.username = info.user + }) + + this.system.on("presence_state", state => { + this.presences = Presence.syncState({}, state) + this.renderPresences(this.presences) + }) + + this.system.on("presence_diff", diff => { + // ignore initial presence_diff result, handle presence_state first + if (this.presences !== undefined) { + this.presences = Presence.syncDiff(this.presences, diff) + this.renderPresences(this.presences) + } + }) + + this.system.join() } + /** + * Handler for presence responses + */ + renderPresences(presences) + { + let userPresence + + if (this.userStatusWidget && (userPresence = presences[this.username])) { + userPresence = this.listBy(this.username, userPresence) + this.userStatusWidget.render(userPresence) + } + + if (this.userListWidget) { + presences = Presence.list(presences, this.listBy) + this.userListWidget.render(presences) + } + } + + listBy(user, {metas: metas}) + { + return { + user: user, + status: metas[0].status + } + } + + /** + * User status change + */ + setStatus(status) + { + this.system.push('set-status', {status: status}); + } + + static extend(obj, src) { - Object.keys(src).forEach(function(key) { obj[key] = src[key]; }); - return obj; + Object.keys(src).forEach(function(key) { obj[key] = src[key] }) + return obj } } export default KolabChat diff --git a/web/static/js/app.js b/web/static/js/app.js index e8dcdd7..7d4f880 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -1,27 +1,32 @@ // Brunch automatically concatenates all files in your // watched paths. Those paths can be configured at // config.paths.watched in "brunch-config.js". // // However, those files will only be executed if // explicitly imported. The only exception are files // in vendor, which are never wrapped in imports and // therefore are always executed. // Import dependencies // // If you no longer want to use a dependency, remember // to also remove its path from "config.paths.watched". import "phoenix_html" // Import local files // // Local files can be imported directly using relative // paths "./socket" or full ones "web/static/js/socket". import KolabChat from "./api" -let chat = new KolabChat(); +// Initialize KolabChat API object +let chat = new KolabChat({ + userListElement: "userlist", + userStatusElement: "userstatus", +}) +// If user is authenticated start the app if (window.userToken) { - chat.init({token: window.userToken}); + chat.init({token: window.userToken}) } diff --git a/web/static/js/widgets/userlist.js b/web/static/js/widgets/userlist.js new file mode 100644 index 0000000..42bb948 --- /dev/null +++ b/web/static/js/widgets/userlist.js @@ -0,0 +1,26 @@ + +class UserListWidget +{ + constructor(id, config) + { + this.config = config || {} + this.id = id + } + + /** + * Render users list + */ + render(presences) + { + let userListElement = document.getElementById(this.id) + + userListElement.innerHTML = presences.map(presence => ` +
<%= get_flash(@conn, :info) %>
<%= get_flash(@conn, :error) %>
<%= gettext "Real-time communication for the Kolab groupware system." %>
+ <%= if @conn.assigns[:user] do %> + Users List: +<%= gettext "Real-time communication for the Kolab groupware system." %>
+ <% end %>