diff --git a/assets/js/api.js b/assets/js/api.js --- a/assets/js/api.js +++ b/assets/js/api.js @@ -2,8 +2,10 @@ import {Socket, LongPoll, Presence} from "phoenix" import UserListWidget from "./widgets/userlist" import UserStatusWidget from "./widgets/userstatus" +import ChannelListWidget from "./widgets/channellist" import ChatInputWidget from "./widgets/chatinput" import ChatRoomWidget from "./widgets/chatroom" +import ChatDetailsWidget from "./widgets/chatdetails" class KolabChat { @@ -16,6 +18,8 @@ * - 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 + * - chatDetailsElement: Id of HTML element where to put chat room details widget + * - channelListElement: Id of HTML element where to put channellist widget */ constructor(config) { @@ -64,6 +68,7 @@ if (this.config.userListElement && $('#' + this.config.userListElement).length) { config = { username: this.username, + title: true, openChat: (e, user) => { this.openChat(e, user) } } this.userListWidget = new UserListWidget(this.config.userListElement, config) @@ -77,6 +82,23 @@ this.userStatusWidget = new UserStatusWidget(this.config.userStatusElement, config) } + if (this.config.channelListElement && $('#' + this.config.channelListElement).length) { + config = { + username: this.username, + title: true, + openChat: (e, roomId) => { this.openExistingChat(roomId) }, + createChat: (e) => { this.openChat(e); } + } + this.channelListWidget = new ChannelListWidget(this.config.channelListElement, config) + } + + if (this.config.chatDetailsElement && $('#' + this.config.chatDetailsElement).length) { + config = { + submit: (e, data) => { this.updateChatDetails(data); } + } + this.chatDetailsWidget = new ChatDetailsWidget(this.config.chatDetailsElement, config) + } + if (this.config.chatRoomElement && $('#' + this.config.chatRoomElement).length) { this.chatRoomWidget = new ChatRoomWidget(this.config.chatRoomElement) } @@ -131,7 +153,8 @@ { let channelName = roomId.startsWith("room:") ? roomId : "room:" + roomId - this.chat = this.socket.channel(channelName) + + this.chat = this.socket.channel(channelName, {context: this.config.context}) if (this.chatRoomWidget) { this.chat.on("new:message", message => { @@ -139,6 +162,12 @@ }) } + if (this.chatDetailsWidget) { + this.chat.on("info", message => { + this.chatDetailsWidget.setRoom(message) + }) + } + let join = this.chat.join() if (invitees) { @@ -151,7 +180,7 @@ */ initNotifications(userId) { - this.notifications = this.socket.channel("user:" + userId) + this.notifications = this.socket.channel("user:" + userId, {context: this.config.context}) this.notifications.on("notify:invite", message => { console.log("Invite from " + message.user + " to room " + message.room) @@ -231,7 +260,11 @@ openChat(event, user) { let windowName = 'KolabChat' + new Date().getTime() - let url = "/chat/?token=" + encodeURIComponent(this.config.token) + "&invite=" + encodeURIComponent(user) + let url = "/chat/?token=" + encodeURIComponent(this.config.token) + + if (user) { + url += "&invite=" + encodeURIComponent(user) + } var extwin = window.open(url, windowName); } @@ -248,6 +281,14 @@ var extwin = window.open(url, windowName); } + /** + * Update chat room details (e.g. name) + */ + updateChatDetails(data) + { + // TODO + } + static extend(obj, src) { Object.keys(src).forEach(function(key) { obj[key] = src[key] }) diff --git a/assets/js/app.js b/assets/js/app.js --- a/assets/js/app.js +++ b/assets/js/app.js @@ -24,7 +24,9 @@ let chat = new KolabChat({ userListElement: "userlist", userStatusElement: "userstatus", + channelListElement: "channellist", chatRoomElement: "chat_txt", + chatDetailsElement: "chat_details", chatInputElement: "chat_txt_input" }) diff --git a/assets/js/widgets/channellist.js b/assets/js/widgets/channellist.js new file mode 100644 --- /dev/null +++ b/assets/js/widgets/channellist.js @@ -0,0 +1,87 @@ + +class ChannelListWidget +{ + /** + * Configuration: + * - username: Current user name + * - openChat: Callback for "Open" button + * - createChat: Callback for "Create" button + * - title: Enable list header with Create button + */ + constructor(id, config) + { + this.config = config || {} + this.id = id + this.render([]) + } + + setUser(username, userId) + { + this.config.username = username + this.config.userId = userId + } + + + /** + * Render channels list + */ + render(channels) + { + let title = '' + let btn = '' + let list = $('#' + this.id) + let config = this.config + let html = channels.map(channel => { + // TODO: List only public channels and channels the user has been invited to + let buttons = this.buttons(channel) + return ` +
  • + #${channel.name} + ${buttons} +
  • ` + }) + .join("") + + if (config.title === true) { + if (config.createChat) { + btn = ` + + ` + } + + title = `
    Channels ${btn}
    ` // TODO: Localization + } + + list.html(title + '') + + $('button', list).on('click', function(e) { + let action = $(this).data('action') + if (action && config[action]) { + config[action](e, $(this).parents('li').data('channel')) + } + }) + } + + /** + * Render channel list record buttons + */ + buttons(channel) + { + let buttons = '' + + if (this.config.openChat) { + let btn_name = ' Open' // TODO: localization + buttons += `` + } + + if (buttons) { + buttons = '
    ' + buttons + '
    ' + } + + return buttons + } +} + +export default ChannelListWidget diff --git a/assets/js/widgets/chatdetails.js b/assets/js/widgets/chatdetails.js new file mode 100644 --- /dev/null +++ b/assets/js/widgets/chatdetails.js @@ -0,0 +1,51 @@ + +class ChatDetailsWidget +{ + /** + * Configuration: + * - submit: Callback for chat details update (only name for now) + */ + constructor(id, config) + { + this.config = config || {} + this.id = id + this.render() + } + + /** + * Set chat room details and re-render the widget + */ + setRoom(room) + { + this.render(room) + } + + /** + * Renders text chat input widget + */ + render(room) + { + if (!room || !room.id) { + return $('#' + this.id).html('') + } + + // TODO: Localization + + let room_name = room.name || ''; + let html = ` +
    Room id: ${room.id}
    +
    Room name:
    + ` + + $('#' + 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) + } + }) + } +} + +export default ChatDetailsWidget diff --git a/assets/js/widgets/userlist.js b/assets/js/widgets/userlist.js --- a/assets/js/widgets/userlist.js +++ b/assets/js/widgets/userlist.js @@ -4,12 +4,14 @@ /** * Configuration: * - username: Current user name - * - openChat: callback for "Open chat" button + * - openChat: Callback for "Open chat" button + * - title: Enables list header */ constructor(id, config) { this.config = config || {} this.id = id + this.render([]) } setUser(username, userId) @@ -24,6 +26,7 @@ */ render(presences) { + let title = '' let list = $('#' + this.id) let config = this.config let html = presences.map(presence => { @@ -38,7 +41,11 @@ }) .join("") - list.html(html) + if (config.title === true) { + title = '
    Users
    ' // TODO: Localization + } + + list.html(title + '') $('button', list).on('click', function(e) { let action = $(this).data('action') @@ -56,7 +63,7 @@ let buttons = '' if (this.config.openChat) { - let btn_name = ' Open chat' + let btn_name = ' Open chat' // TODO: Localization buttons += `` } diff --git a/assets/js/widgets/userstatus.js b/assets/js/widgets/userstatus.js --- a/assets/js/widgets/userstatus.js +++ b/assets/js/widgets/userstatus.js @@ -22,6 +22,8 @@ "invisible", "offline" ]; + + // TODO: render() with defalt state here } /** 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.Web.Endpoint, []), # Start phoenix presence module supervisor(KolabChat.Web.Presence, []), + # Start Rooms database + worker(KolabChat.Rooms, []), # Start your own worker by calling: KolabChat.Worker.start_link(arg1, arg2, arg3) # worker(KolabChat.Worker, [arg1, arg2, arg3]), ] diff --git a/lib/kolab_chat/rooms.ex b/lib/kolab_chat/rooms.ex new file mode 100644 --- /dev/null +++ b/lib/kolab_chat/rooms.ex @@ -0,0 +1,59 @@ +defmodule KolabChat.Rooms do + use GenServer + + def get(roomId) do + GenServer.call(__MODULE__, {:get, roomId}) + end + + def find(room) do + GenServer.call(__MODULE__, {:find, room}) + end + + def set(roomId, room) do + GenServer.call(__MODULE__, {:set, roomId, room}) + end + + def start_link do + :ets.new(:rooms, [:set, :named_table, :public]) + GenServer.start_link(__MODULE__, :ok, name: __MODULE__) + end + + def handle_call({:get, roomId}, _from, state) do + room = + case lookup_rooms(roomId) do + nil -> nil + room -> Map.put(room, :id, roomId) + end + + {:reply, room, state} + end + + # Finding a room by any attribute value, supported: id, name + def handle_call({:find, room}, _from, state) do + has_id = Map.has_key?(room, :id) + room = + case has_id and lookup_rooms(room.id) do + # TODO: searching by name alias + nil -> nil + metadata -> Map.merge(room, metadata) + end + + {:reply, room, state} + end + + def handle_call({:set, roomId, metadata}, _from, state) do + metadata = lookup_rooms(roomId, %{}) + |> Map.merge(metadata) + |> Map.delete(:id) + + :ets.insert(:rooms, {roomId, metadata}) + {:reply, :ok, state} + end + + defp lookup_rooms(roomId, default \\ nil) do + case :ets.lookup(:rooms, roomId) do + [{_, room}] -> room + _ -> default + end + end +end diff --git a/lib/kolab_chat/web/channels/presence.ex b/lib/kolab_chat/web/channels/presence.ex --- a/lib/kolab_chat/web/channels/presence.ex +++ b/lib/kolab_chat/web/channels/presence.ex @@ -74,4 +74,83 @@ """ use Phoenix.Presence, otp_app: :kolab_chat, pubsub_server: KolabChat.PubSub + + require Amnesia + require Amnesia.Helper + + alias KolabChat.Database + + @status [ + # user is available for chat + :online, + :away, + # user is connected and visible, but not available + :busy, + :unavailable, + # user is shown as offline + :invisible, + :offline + ] + + def track_presence(socket) do + track(socket, socket.assigns.user.id, %{ + username: socket.assigns.user.username, + status: get_status(socket), + context: socket.assigns.context + }) + end + + def update_status(socket, status) do + case check_status(status) do + :invalid -> + socket + status -> + update(socket, socket.assigns.user.id, %{ + username: socket.assigns.user.username, + status: status, + context: socket.assigns.context + }) + socket + end + end + # Get the last user/context status from the database + def get_status(socket) do + key = Integer.to_string(socket.assigns.user.id) <> ":" <> socket.assigns.context + + Amnesia.transaction do + case Database.Status.read(key) do + # use last status + %Database.Status{status: status} -> status + # otherwise set status to online + _ -> :online + end + end + end + + # Save the current user/context status to the database + def set_status(socket, status) do + case check_status(status) do + :invalid -> + socket + status -> + key = Integer.to_string(socket.assigns.user.id) <> ":" <> socket.assigns.context + Amnesia.transaction do + Database.Status.write(%Database.Status{key: key, status: status}) + end + socket + end + end + + # Makes sure the provided status name is supported + # Returns status name as an atom + defp check_status(status) do + status = String.to_atom(status) + + if Enum.member?(@status, status) do + status + else + :invalid + end + end + end diff --git a/lib/kolab_chat/web/channels/room_channel.ex b/lib/kolab_chat/web/channels/room_channel.ex --- a/lib/kolab_chat/web/channels/room_channel.ex +++ b/lib/kolab_chat/web/channels/room_channel.ex @@ -2,9 +2,16 @@ use KolabChat.Web, :channel alias KolabChat.Web.Endpoint + alias KolabChat.Rooms @spec join(topic :: binary(), args :: map(), socket :: pid()) :: {:ok, socket :: pid()} - def join("room:" <> room_name, _, socket) do + def join("room:" <> room_name, args, socket) do + room = Rooms.find(%{:id => room_name, :name => room_name}) + socket = socket + |> assign(:room, room) + |> assign(:context, get_context(args)) + + send self(), :after_join {:ok, socket} end @@ -27,4 +34,19 @@ def handle_in("ctl:invite", params, socket) do {:noreply, socket} end + + @spec handle_info(:after_join, socket :: pid()) :: {:noreply, socket :: pid()} + def handle_info(:after_join, socket) do + push socket, "info", socket.assigns.room + # Presence.track_presence(socket) + {:noreply, socket} + end + + defp get_context(args) do + case args do + %{:context => context} -> context + _ -> "default" + end + end + end diff --git a/lib/kolab_chat/web/channels/system_channel.ex b/lib/kolab_chat/web/channels/system_channel.ex --- a/lib/kolab_chat/web/channels/system_channel.ex +++ b/lib/kolab_chat/web/channels/system_channel.ex @@ -1,25 +1,9 @@ defmodule KolabChat.Web.SystemChannel do use KolabChat.Web, :channel - @status [ - # user is available for chat - :online, - :away, - # user is connected and visible, but not available - :busy, - :unavailable, - # user is shown as offline - :invisible, - :offline - ] - @spec join(topic :: binary(), args :: map(), socket :: pid()) :: {:ok, socket :: pid()} - def join("system", %{"context" => context}, socket) do - perform_join(context, socket) - end - - def join("system", _args, socket) do - perform_join("default", socket) + def join("system", args, socket) do + perform_join(get_context(args), socket) end @spec handle_info(:after_join, socket :: pid()) :: {:noreply, socket :: pid()} @@ -27,21 +11,16 @@ push socket, "presence_state", Presence.list(socket) push socket, "info", %{user: socket.assigns.user.username, userId: socket.assigns.user.id} - Presence.track(socket, socket.assigns.user.id, %{ - username: socket.assigns.user.username, - status: get_user_status(socket), - context: socket.assigns.context - }) + Presence.track_presence(socket) {:noreply, socket} end @spec handle_in(topic :: binary, args :: map(), socket :: pid()) :: {:noreply, socket :: pid()} def handle_in("set-status", %{"status" => status}, socket) do - status = check_status(status) socket - |> update_presence_status(status) - |> set_user_status(status) + |> Presence.update_status(status) + |> Presence.set_status(status) {:noreply, socket} end @@ -53,58 +32,11 @@ {:ok, socket} end - defp update_presence_status(socket, :invalid), do: socket - defp update_presence_status(socket, status) do - Presence.update(socket, socket.assigns.user.id, %{ - username: socket.assigns.user.username, - status: status, - context: socket.assigns.context - }) - - socket - end - - # Makes sure the provided status name is supported - # Returns status name as an atom - defp check_status(status) do - status = String.to_atom(status) - - if Enum.member?(@status, status) do - status - else - :invalid + defp get_context(args) do + case args do + %{:context => context} -> context + _ -> "default" end end - # Get the last user/context status from the database - defp get_user_status(socket) do - require Amnesia - require Amnesia.Helper - - key = Integer.to_string(socket.assigns.user.id) <> ":" <> socket.assigns.context - - Amnesia.transaction do - case Database.Status.read(key) do - # use last status - %Database.Status{status: status} -> status - # otherwise set status to online - _ -> :online - end - end - end - - # Save the current user/context status to the database - defp set_user_status(socket, :invalid), do: socket - defp set_user_status(socket, status) do - require Amnesia - require Amnesia.Helper - - key = Integer.to_string(socket.assigns.user.id) <> ":" <> socket.assigns.context - - Amnesia.transaction do - Database.Status.write(%Database.Status{key: key, status: status}) - end - - socket - end end diff --git a/lib/kolab_chat/web/controllers/chat_controller.ex b/lib/kolab_chat/web/controllers/chat_controller.ex --- a/lib/kolab_chat/web/controllers/chat_controller.ex +++ b/lib/kolab_chat/web/controllers/chat_controller.ex @@ -1,9 +1,12 @@ defmodule KolabChat.Web.ChatController do use KolabChat.Web, :controller + alias KolabChat.Rooms + plug :put_layout, "chat.html" def index(conn, %{"room" => room} = params) do + metadata = Rooms.find(%{:id => room, :name => room}) create_room(conn, params, room) end @@ -11,9 +14,16 @@ create_room(conn, params, UUID.uuid4()) end - defp create_room(conn, params, roomId) do + defp create_room(conn, params, roomId, room \\ %{}) do + room = %{ + :id => roomId, + :creator => conn.assigns.user.id + } + + Rooms.set(roomId, room) + conn - |> assign(:room, roomId) + |> assign(:room, room) |> assign_invitees(params) |> render("index.html") end diff --git a/lib/kolab_chat/web/templates/chat/index.html.eex b/lib/kolab_chat/web/templates/chat/index.html.eex --- a/lib/kolab_chat/web/templates/chat/index.html.eex +++ b/lib/kolab_chat/web/templates/chat/index.html.eex @@ -1,3 +1,3 @@ -Room id is: <%= @room %> +
    diff --git a/lib/kolab_chat/web/templates/layout/chat.html.eex b/lib/kolab_chat/web/templates/layout/chat.html.eex --- a/lib/kolab_chat/web/templates/layout/chat.html.eex +++ b/lib/kolab_chat/web/templates/layout/chat.html.eex @@ -19,7 +19,7 @@ <%= if @conn.assigns[:user] do %> - +