diff --git a/web/channels/room_channel.ex b/web/channels/room_channel.ex new file mode 100644 index 0000000..6f20780 --- /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/user_socket.ex b/web/channels/user_socket.ex index 6daa9ee..980d51c 100644 --- a/web/channels/user_socket.ex +++ b/web/channels/user_socket.ex @@ -1,42 +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 # 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/controllers/chat_controller.ex b/web/controllers/chat_controller.ex new file mode 100644 index 0000000..9fb4fad --- /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 index 803cf94..8d77a68 100644 --- a/web/controllers/plugs/set_user.ex +++ b/web/controllers/plugs/set_user.ex @@ -1,19 +1,30 @@ defmodule KolabChat.Plugs.SetUser do import Plug.Conn alias KolabChat.Repo alias KolabChat.User 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) cond do user = user_id && Repo.get(User, user_id) -> assign(conn, :user, user) true -> assign(conn, :user, nil) end end end diff --git a/web/router.ex b/web/router.ex index eba53cb..e67850f 100644 --- a/web/router.ex +++ b/web/router.ex @@ -1,35 +1,42 @@ defmodule KolabChat.Router do use KolabChat.Web, :router pipeline :browser do plug :accepts, ["html"] plug :fetch_session plug :fetch_flash plug :protect_from_forgery plug :put_secure_browser_headers plug KolabChat.Plugs.Locale plug KolabChat.Plugs.SetUser end pipeline :api do plug :accepts, ["json"] 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 post "/default/callback", AuthController, :default_callback get "/logout", AuthController, :logout end # Other scopes may use custom stacks. # scope "/api", KolabChat do # pipe_through :api # end end diff --git a/web/static/css/app.css b/web/static/css/app.css index 315be25..9efcce1 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -1,21 +1,33 @@ /* 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; } + +#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 index 4a57d10..923c16e 100644 --- a/web/static/css/widgets.css +++ b/web/static/css/widgets.css @@ -1,13 +1,26 @@ /* Style for chat application widgets */ -.status-online .glyphicon { +.status-online .glyphicon-user { color: green; } -.status-away .glyphicon { +.status-away .glyphicon-user { color: grey; } -.status-busy .glyphicon { +.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 index ee4d001..5373a44 100644 --- a/web/static/js/api.js +++ b/web/static/js/api.js @@ -1,129 +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() { - if (this.config.userListElement) { - this.userListWidget = new UserListWidget(this.config.userListElement) + 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) { - let config = {statusChange: status => { this.setStatus(status) }} + 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 index 7d4f880..87d2b2a 100644 --- a/web/static/js/app.js +++ b/web/static/js/app.js @@ -1,32 +1,37 @@ // 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" // 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}) + chat.init({ + token: window.userToken, + roomId: window.roomId + }) } diff --git a/web/static/js/widgets/chatinput.js b/web/static/js/widgets/chatinput.js new file mode 100644 index 0000000..6f473aa --- /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 index 0000000..009ba06 --- /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 index 42bb948..a55f6f5 100644 --- a/web/static/js/widgets/userlist.js +++ b/web/static/js/widgets/userlist.js @@ -1,26 +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 userListElement = document.getElementById(this.id) - - userListElement.innerHTML = presences.map(presence => ` -