diff --git a/brunch-config.js b/brunch-config.js --- a/brunch-config.js +++ b/brunch-config.js @@ -54,6 +54,9 @@ babel: { // Do not use ES6 compiler in vendor code ignore: [/web\/static\/vendor/] + }, + copycat: { + fonts: ["node_modules/bootstrap/fonts"] } }, @@ -64,6 +67,11 @@ }, npm: { - enabled: true + enabled: true, + globals: { + $: "jquery", + jQuery: "jquery", + bootstrap: "bootstrap" + } } }; 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/package.json b/package.json --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ }, "dependencies": { "phoenix": "file:deps/phoenix", - "phoenix_html": "file:deps/phoenix_html" + "phoenix_html": "file:deps/phoenix_html", + "jquery": ">=2.1", + "bootstrap": "~3.3.7" }, "devDependencies": { "babel-brunch": "~6.0.0", @@ -15,6 +17,7 @@ "clean-css-brunch": "~2.0.0", "css-brunch": "~2.0.0", "javascript-brunch": "~2.0.0", - "uglify-js-brunch": "~2.0.1" + "uglify-js-brunch": "~2.0.1", + "copycat-brunch": "~1.1.0" } } 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/room_channel.ex b/web/channels/room_channel.ex new file mode 100644 --- /dev/null +++ b/web/channels/room_channel.ex @@ -0,0 +1,12 @@ +defmodule KolabChat.RoomChannel do + use KolabChat.Web, :channel + + def join("room:lobby", _, socket) do + {:ok, socket} + end + + def handle_in("new:message", message, socket) do + broadcast! socket, "new:message", %{user: message["user"], body: message["body"]} + {:noreply, socket} + end +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/controllers/chat_controller.ex b/web/controllers/chat_controller.ex new file mode 100644 --- /dev/null +++ b/web/controllers/chat_controller.ex @@ -0,0 +1,11 @@ +defmodule KolabChat.ChatController do + use KolabChat.Web, :controller + + plug :put_layout, "chat.html" + + def index(conn, %{"room" => room} = _params) do + conn + |> assign(:room, room) + |> render("index.html") + end +end diff --git a/web/controllers/plugs/set_user.ex b/web/controllers/plugs/set_user.ex --- a/web/controllers/plugs/set_user.ex +++ b/web/controllers/plugs/set_user.ex @@ -6,6 +6,17 @@ def init(params), do: params + # token authentication + def call(%{"params": %{"token" => token}} = conn, _params) do + case Phoenix.Token.verify(conn, "user", token, max_age: 86400) do + {:ok, user_id} -> + assign(conn, :user, Repo.get!(User, user_id)) + _ -> + assign(conn, :user, nil) + end + end + + # session authentication def call(conn, _params) do user_id = get_session(conn, :user_id) diff --git a/web/router.ex b/web/router.ex --- a/web/router.ex +++ b/web/router.ex @@ -16,11 +16,18 @@ end scope "/", KolabChat do - pipe_through :browser # Use the default browser stack + pipe_through :browser get "/", PageController, :index end + scope "/chat", KolabChat do + pipe_through :browser + + get "/", ChatController, :index + get "/:room", ChatController, :index + end + scope "/auth", KolabChat do pipe_through :browser 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,23 @@ display: block; margin-bottom: 3px; } + +ul.userlist li { + text-align: left; +} + +ul.userlist li small { + color: #666; +} + +#chat_txt { + height: 300px; + width: 100%; + border: 1px solid #ccc; + border-radius: 3px; + margin-bottom: 5px; +} + +#chat_txt_input { + width: 100%; +} 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,26 @@ +/* Style for chat application widgets */ + +.status-online .glyphicon-user { + color: green; +} + +.status-away .glyphicon-user { + color: grey; +} + +.status-busy .glyphicon-user { + color: red; +} + +.userlist .btn-group { + float: right; +} + +.textchat { + overflow-x: hidden; + overflow-y: scroll; +} + +.textchat p { + margin: 0; +} 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,198 @@ + +import {Socket, LongPoll, Presence} from "phoenix" +import UserListWidget from "./widgets/userlist" +import UserStatusWidget from "./widgets/userstatus" +import ChatInputWidget from "./widgets/chatinput" +import ChatRoomWidget from "./widgets/chatroom" + +class KolabChat +{ + /** + * Configuration parameters: + * - token: User session token + * - roomId: Chat room Id to join in + * - userListElement: Id of HTML element where to put userslist widget + * - userStatusElement: Id of HTML element where to put users status widget + * - chatInputElement: Id of HTML element which is a text chat input + * - chatRoomElement: Id of HTML element where to put text conversation + */ + 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() + if (this.config.roomId) { + this.initRoom(this.config.roomId) + } + }) + + this.socket.connect() + } + + /** + * Initializes configured UI widgets + */ + initWidgets() + { + let config + + if (this.config.userListElement && $('#' + this.config.userListElement).length) { + config = { + username: this.username, + openChat: (e, user) => { this.openChat(e, user) } + } + this.userListWidget = new UserListWidget(this.config.userListElement, config) + } + + if (this.config.userStatusElement && $('#' + this.config.userStatusElement).length) { + config = { + username: this.username, + statusChange: status => { this.setStatus(status) } + } + this.userStatusWidget = new UserStatusWidget(this.config.userStatusElement, config) + } + + if (this.config.chatRoomElement && $('#' + this.config.chatRoomElement).length) { + this.chatRoomWidget = new ChatRoomWidget(this.config.chatRoomElement) + } + + if (this.config.chatInputElement && $('#' + this.config.chatInputElement).length) { + config = { + submit: (e, msg) => { this.sendTxtMessage(e, msg) } + } + this.chatInputWidget = new ChatInputWidget(this.config.chatInputElement, 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() + } + + /** + * Initialize chat channel + */ + initRoom(roomId) + { + this.chat = this.socket.channel("room:" + roomId) + + this.chat.on("new:message", message => { + this.chatRoomWidget.append(message.user, message.body) + }) + + this.chat.join() + } + + /** + * Send text message to the chat room + */ + sendTxtMessage(event, message) + { + this.chat.push("new:message", { + user: this.username, // TODO: this is not really needed + body: message + }) + } + + /** + * 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}); + } + + /** + * Open chat window (and create a new chat room) + */ + openChat(event, user) + { + // TODO: Use 'system' channel to create a chat room first + let roomId = "lobby" + + let windowName = 'KolabChat' + new Date().getTime() + let url = "/chat/" + encodeURIComponent(roomId) + + "/?token=" + encodeURIComponent(this.config.token) + + var extwin = window.open(url, windowName); + } + + + 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,20 @@ // 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", + chatRoomElement: "chat_txt", + chatInputElement: "chat_txt_input" +}) + +// If user is authenticated start the app +if (window.userToken) { + chat.init({ + token: window.userToken, + roomId: window.roomId + }) +} 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": -// -// -// -// 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/chatinput.js b/web/static/js/widgets/chatinput.js new file mode 100644 --- /dev/null +++ b/web/static/js/widgets/chatinput.js @@ -0,0 +1,36 @@ + +class ChatInputWidget +{ + /** + * Configuration: + * - submit: handler function for submit the text to a chat room + */ + constructor(id, config) + { + this.config = config || {} + this.id = id + this.render() + } + + /** + * Renders text chat input widget + */ + render() + { + let icon = '' + let html = `${icon} + ` + + $('#' + this.id) + .html(html) + .on('keypress', 'input', e => { + let txt + if (e.keyCode == 13 && this.config.submit && (txt = $(e.target).val())) { + this.config.submit(e, txt) + $(e.target).val('') + } + }) + } +} + +export default ChatInputWidget diff --git a/web/static/js/widgets/chatroom.js b/web/static/js/widgets/chatroom.js new file mode 100644 --- /dev/null +++ b/web/static/js/widgets/chatroom.js @@ -0,0 +1,35 @@ + +class ChatRoomWidget +{ + constructor(id, config) + { + this.config = config || {} + this.id = id + this.render() + } + + /** + * Renders text chat room widget + */ + render() + { + } + + /** + * Appends text message to the chat room widget + */ + append(user, text) + { + user = ChatRoomWidget.sanitize(user) + text = ChatRoomWidget.sanitize(text) + + $("#" + this.id).append(`
[${user}]: ${text}
`) + } + + static sanitize(str) + { + return $("").text(str).html() + } +} + +export default ChatRoomWidget 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,62 @@ + +class UserListWidget +{ + /** + * Configuration: + * - username: Current user name + * - openChat: callback for "Open chat" button + */ + constructor(id, config) + { + this.config = config || {} + this.id = id + } + + /** + * Render users list + */ + render(presences) + { + let list = $('#' + this.id) + let config = this.config + let html = presences.map(presence => { + let buttons = this.buttons(presence) + return ` +<%= 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 %>