Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F117877637
D351.1775338513.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Authored By
Unknown
Size
17 KB
Referenced Files
None
Subscribers
None
D351.1775338513.diff
View Options
diff --git a/lib/kolab_chat.ex b/lib/kolab_chat.ex
--- a/lib/kolab_chat.ex
+++ b/lib/kolab_chat.ex
@@ -12,6 +12,8 @@
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]),
]
diff --git a/web/channels/presence.ex b/web/channels/presence.ex
new file mode 100644
--- /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
--- /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
--- a/web/channels/user_socket.ex
+++ b/web/channels/user_socket.ex
@@ -1,26 +1,31 @@
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`.
- #
- # See `Phoenix.Token` documentation for examples in
- # performing token verification on connect.
- def connect(_params, socket) do
- {:ok, socket}
+ 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:
diff --git a/web/controllers/auth_controller.ex b/web/controllers/auth_controller.ex
--- a/web/controllers/auth_controller.ex
+++ b/web/controllers/auth_controller.ex
@@ -1,8 +1,6 @@
defmodule KolabChat.AuthController do
use KolabChat.Web, :controller
- alias KolabChat.User
-
@doc """
Handler for the default logon form
"""
diff --git a/web/static/css/app.css b/web/static/css/app.css
--- a/web/static/css/app.css
+++ b/web/static/css/app.css
@@ -11,3 +11,11 @@
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
--- /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
new file mode 100644
--- /dev/null
+++ b/web/static/js/api.js
@@ -0,0 +1,129 @@
+
+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 || {}
+ }
+
+ /**
+ * Initialize WebSocket communication
+ */
+ init(config)
+ {
+ if (config)
+ this.config = KolabChat.extend(this.config, config)
+
+ this.initWidgets()
+
+ // TODO: for integration with external systems we'll use configurable full wss:// url
+
+ this.socket = new Socket("/socket", {
+ params: {token: this.config.token},
+ logger: ((kind, msg, data) => { console.log(`${kind}: ${msg}`, data) }),
+ })
+
+ 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
+ }
+}
+
+export default KolabChat
diff --git a/web/static/js/app.js b/web/static/js/app.js
--- a/web/static/js/app.js
+++ b/web/static/js/app.js
@@ -18,4 +18,15 @@
// Local files can be imported directly using relative
// paths "./socket" or full ones "web/static/js/socket".
-// import socket from "./socket"
+import KolabChat from "./api"
+
+// 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})
+}
diff --git a/web/static/js/socket.js b/web/static/js/socket.js
deleted file mode 100644
--- a/web/static/js/socket.js
+++ /dev/null
@@ -1,62 +0,0 @@
-// NOTE: The contents of this file will only be executed if
-// you uncomment its entry in "web/static/js/app.js".
-
-// To use Phoenix channels, the first step is to import Socket
-// and connect at the socket path in "lib/my_app/endpoint.ex":
-import {Socket} from "phoenix"
-
-let socket = new Socket("/socket", {params: {token: window.userToken}})
-
-// When you connect, you'll often need to authenticate the client.
-// For example, imagine you have an authentication plug, `MyAuth`,
-// which authenticates the session and assigns a `:current_user`.
-// If the current user exists you can assign the user's token in
-// the connection for use in the layout.
-//
-// In your "web/router.ex":
-//
-// pipeline :browser do
-// ...
-// plug MyAuth
-// plug :put_user_token
-// end
-//
-// defp put_user_token(conn, _) do
-// if current_user = conn.assigns[:current_user] do
-// token = Phoenix.Token.sign(conn, "user socket", current_user.id)
-// assign(conn, :user_token, token)
-// else
-// conn
-// end
-// end
-//
-// Now you need to pass this token to JavaScript. You can do so
-// inside a script tag in "web/templates/layout/app.html.eex":
-//
-// <script>window.userToken = "<%= assigns[:user_token] %>";</script>
-//
-// You will need to verify the user token in the "connect/2" function
-// in "web/channels/user_socket.ex":
-//
-// def connect(%{"token" => token}, socket) do
-// # max_age: 1209600 is equivalent to two weeks in seconds
-// case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
-// {:ok, user_id} ->
-// {:ok, assign(socket, :user, user_id)}
-// {:error, reason} ->
-// :error
-// end
-// end
-//
-// Finally, pass the token on connect as below. Or remove it
-// from connect if you don't care about authentication.
-
-socket.connect()
-
-// Now that you are connected, you can join channels with a topic:
-let channel = socket.channel("topic:subtopic", {})
-channel.join()
- .receive("ok", resp => { console.log("Joined successfully", resp) })
- .receive("error", resp => { console.log("Unable to join", resp) })
-
-export default socket
diff --git a/web/static/js/widgets/userlist.js b/web/static/js/widgets/userlist.js
new file mode 100644
--- /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 => `
+ <li class="list-group-item status-${presence.status}">
+ <span class="glyphicon glyphicon-user"></span> ${presence.user}
+ </li>
+ `)
+ .join("")
+ }
+}
+
+export default UserListWidget
diff --git a/web/static/js/widgets/userstatus.js b/web/static/js/widgets/userstatus.js
new file mode 100644
--- /dev/null
+++ b/web/static/js/widgets/userstatus.js
@@ -0,0 +1,47 @@
+
+class UserStatusWidget
+{
+ /**
+ * Configuration:
+ * - statusChange: handler function for status change
+ */
+ constructor(id, config)
+ {
+ this.config = config || {}
+ this.id = id
+ }
+
+ /**
+ * Renders user status widget
+ */
+ render(presence)
+ {
+ let userStatusElement = document.getElementById(this.id)
+ let icon = '<span class="glyphicon glyphicon-user"></span>'
+
+ userStatusElement.innerHTML = `
+ <div class="dropdown">
+ <button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
+ <span class="status-${presence.status}">
+ ${icon} ${presence.user}
+ </span>
+ <span class="caret"></span>
+ </button>
+ <ul class="dropdown-menu" aria-labelledby="dropdownMenu1">
+ <li><a href="#" class="status-online">${icon} Online</a></li>
+ <li><a href="#" class="status-away">${icon} Away</a></li>
+ <li><a href="#" class="status-busy">${icon} Busy</a></li>
+ </ul>
+ </div>
+ `
+
+ $('.dropdown-menu > li', userStatusElement).click(e => {
+ let status_class = $(e.target).attr('class')
+ if (this.config.statusChange && !$('button > span:first', userStatusElement).hasClass(status_class)) {
+ this.config.statusChange(status_class.replace(/^status-/, ''))
+ }
+ })
+ }
+}
+
+export default UserStatusWidget
diff --git a/web/templates/layout/app.html.eex b/web/templates/layout/app.html.eex
--- a/web/templates/layout/app.html.eex
+++ b/web/templates/layout/app.html.eex
@@ -18,8 +18,10 @@
<nav role="navigation">
<ul class="nav nav-pills pull-right">
<li><%= link gettext("Logout"), to: "/auth/logout" %></li>
+ <li id="userstatus"></li>
</ul>
</nav>
+ <script>window.userToken = "<%= Phoenix.Token.sign(@conn, "user", @conn.assigns[:user].id) %>"</script>
<% else %>
<%= form_for @conn, "/auth/default/callback", [as: :logon], fn f -> %>
<%= text_input f, :username, placeholder: gettext("Username") %>
@@ -38,6 +40,8 @@
</main>
</div> <!-- /container -->
+ <script src="<%= static_path(@conn, "/js/jquery.min.js") %>"></script>
+ <script src="<%= static_path(@conn, "/js/bootstrap.min.js") %>"></script>
<script src="<%= static_path(@conn, "/js/app.js") %>"></script>
</body>
</html>
diff --git a/web/templates/page/index.html.eex b/web/templates/page/index.html.eex
--- a/web/templates/page/index.html.eex
+++ b/web/templates/page/index.html.eex
@@ -1,4 +1,9 @@
<div class="jumbotron">
- <h2><%= gettext "Welcome to %{name}", name: "Kolab Chat!" %></h2>
- <p class="lead"><%= gettext "Real-time communication for the Kolab groupware system." %></p>
+ <%= if @conn.assigns[:user] do %>
+ Users List:
+ <ul id="userlist" class="userlist list-group"></ul>
+ <% else %>
+ <h2><%= gettext "Welcome to %{name}", name: "Kolab Chat!" %></h2>
+ <p class="lead"><%= gettext "Real-time communication for the Kolab groupware system." %></p>
+ <% end %>
</div>
diff --git a/web/web.ex b/web/web.ex
--- a/web/web.ex
+++ b/web/web.ex
@@ -31,6 +31,8 @@
use Phoenix.Controller
alias KolabChat.Repo
+ alias KolabChat.User
+
import Ecto
import Ecto.Query
File Metadata
Details
Attached
Mime Type
text/plain
Expires
Sat, Apr 4, 9:35 PM (1 d, 22 h ago)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
18831263
Default Alt Text
D351.1775338513.diff (17 KB)
Attached To
Mode
D351: Websockets, auth tokens, presence, text chat
Attached
Detach File
Event Timeline