diff --git a/assets/js/api.js b/assets/js/api.js
index 32286f0..5f02e89 100644
--- a/assets/js/api.js
+++ b/assets/js/api.js
@@ -1,228 +1,255 @@
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
* - context: KolabChat instance identifier
* - 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 || {}
if (!this.config.context)
this.config.context = location.hostname;
}
/**
* 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, context: this.config.context},
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.initRoom(this.config.roomId, this.config.invitees)
}
})
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", {context: this.config.context})
this.system.on("info", info => {
this.username = info.user
+ this.userId = info.userId
if (this.userListWidget) {
- this.userListWidget.setUsername(this.username)
+ this.userListWidget.setUser(this.username, this.userId)
}
+
+ this.initNotifications(info.userId)
})
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)
+ initRoom(roomId, invitees)
{
this.chat = this.socket.channel("room:" + roomId)
- this.chat.on("new:message", message => {
- this.chatRoomWidget.append(message.user, message.body)
+ if (this.chatRoomWidget) {
+ this.chat.on("new:message", message => {
+ this.chatRoomWidget.append(message.user, message.body)
+ })
+ }
+
+ let join = this.chat.join()
+
+ if (invitees) {
+ join.receive("ok", () => this.chat.push("ctl:invite", {users: invitees}))
+ }
+ }
+
+ /**
+ * Initialize the user's own notifications channel
+ */
+ initNotifications(userId)
+ {
+ this.notifications = this.socket.channel("user:" + userId)
+
+ this.notifications.on("notify:invite", message => {
+ console.log("Invite from " + message.user + " to room " + message.room)
})
- this.chat.join()
+ this.notifications.join()
+ .receive("ok", () => console.log("Starting to receive notifications"))
+ .receive("error", () => console.log("FAILED to subscribe to notifications. Uh-oh!"))
+ .receive("timeout", () => console.log("notification subscription timedout"))
}
/**
* Send text message to the chat room
*/
sendTxtMessage(event, message)
{
this.chat.push("new:message", {
body: message
})
}
/**
* Handler for presence responses
*/
renderPresences(presences)
{
let userPresence
- if (this.userStatusWidget && (userPresence = presences[this.username])) {
+ if (this.userStatusWidget && (userPresence = presences[this.userId])) {
userPresence = this.listBy(this.username, userPresence, this.config.context)
this.userStatusWidget.render(userPresence)
}
if (this.userListWidget) {
presences = Presence.list(presences, this.listBy)
this.userListWidget.render(presences)
}
}
listBy(user, {metas: metas}, context)
{
let statusWeights = {
offline: 0,
busy: 10,
away: 20,
online: 30
}
// Find "best" availability status for the user
// If set, narrow the result to the current session context
let mostAvailableStatus = metas.reduce((best, meta) => {
if (context && context != meta.context)
return best
return statusWeights[best] > statusWeights[meta.status] ? best : meta.status
},
"offline")
return {
user: user,
+ username: metas[0].username,
status: mostAvailableStatus
}
}
/**
* User status change
*/
setStatus(status)
{
this.system.push('set-status', {status: status});
}
/**
* Open chat window (and create a new chat room)
*/
openChat(event, user)
{
let windowName = 'KolabChat' + new Date().getTime()
- let url = "/chat/?token=" + encodeURIComponent(this.config.token)
+ let url = "/chat/?token=" + encodeURIComponent(this.config.token) + "&invite=" + encodeURIComponent(user)
var extwin = window.open(url, windowName);
}
/**
* Open a chat window to an existing chat
*/
openExistingChat(event, user, roomId)
{
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/assets/js/app.js b/assets/js/app.js
index 87d2b2a..78e82b4 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -1,37 +1,38 @@
// 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,
- roomId: window.roomId
+ roomId: window.roomId,
+ invitees: window.invitees
})
}
diff --git a/assets/js/widgets/userlist.js b/assets/js/widgets/userlist.js
index 4b2b08a..7a10f00 100644
--- a/assets/js/widgets/userlist.js
+++ b/assets/js/widgets/userlist.js
@@ -1,70 +1,71 @@
class UserListWidget
{
/**
* Configuration:
* - username: Current user name
* - openChat: callback for "Open chat" button
*/
constructor(id, config)
{
this.config = config || {}
this.id = id
}
- setUsername(username)
+ setUser(username, userId)
{
this.config.username = username
+ this.config.userId = userId
}
/**
* Render users list
*/
render(presences)
{
let list = $('#' + this.id)
let config = this.config
let html = presences.map(presence => {
- if (presence.user != config.username) {
- let buttons = this.buttons(presence)
+ if (presence.user != config.userId) {
+ let buttons = this.buttons(presence)
return `
- ${presence.user}
+ ${presence.username}
${buttons}
`
}
})
.join("")
list.html(html)
$('button', list).on('click', function(e) {
let action = $(this).data('action')
if (action && config[action]) {
config[action](e, $(this).parents('li').data('user'))
}
})
}
/**
* Render users list record buttons
*/
buttons(presence)
{
let buttons = ''
- if (this.config.openChat) { // && presence.user != this.config.username) {
+ if (this.config.openChat) {
let btn_name = ' Open chat'
buttons += `${btn_name} `
}
if (buttons) {
buttons = '' + buttons + '
'
}
return buttons
}
}
export default UserListWidget
diff --git a/assets/js/widgets/userstatus.js b/assets/js/widgets/userstatus.js
index 6c7d5f9..c1437af 100644
--- a/assets/js/widgets/userstatus.js
+++ b/assets/js/widgets/userstatus.js
@@ -1,63 +1,63 @@
class UserStatusWidget
{
/**
* Configuration:
* - statusChange: handler function for status change
*/
constructor(id, config)
{
this.config = config || {}
this.id = id
// FIXME: should we get that list from the backend?
this.status_list = [
// user is available for chat
"online",
"away",
// user is connected and visible, but not available
"busy",
"unavailable",
// user is shown as offline
"invisible",
"offline"
];
}
/**
* Renders user status widget
*/
render(presence)
{
let userStatusElement = document.getElementById(this.id)
let icon = ' '
let options = list => {
return $.map(list, status =>
`${icon} ${status} `
).join("\n")
}
userStatusElement.innerHTML = `
- ${icon} ${presence.user}
+ ${icon} ${presence.username}
`
$('.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/lib/kolab_chat/web/channels/room_channel.ex b/lib/kolab_chat/web/channels/room_channel.ex
index 3076b5e..3987328 100644
--- a/lib/kolab_chat/web/channels/room_channel.ex
+++ b/lib/kolab_chat/web/channels/room_channel.ex
@@ -1,14 +1,30 @@
defmodule KolabChat.Web.RoomChannel do
use KolabChat.Web, :channel
+ alias KolabChat.Web.Endpoint
+
@spec join(topic :: binary(), args :: map(), socket :: pid()) :: {:ok, socket :: pid()}
- def join("room" <> room_name, _, socket) do
+ def join("room:" <> room_name, _, socket) do
{:ok, socket}
end
@spec handle_in(topic :: binary, args :: map(), socket :: pid()) :: {:noreply, socket :: pid()}
def handle_in("new:message", message, socket) do
broadcast! socket, "new:message", %{user: socket.assigns.user.username, body: message["body"]}
{:noreply, socket}
end
+
+ def handle_in("ctl:invite", %{"users" => invitee}, socket) when is_bitstring(invitee) do
+ Endpoint.broadcast!("user:" <> invitee, "notify:invite", %{ user: socket.assigns.user.username, room: socket.topic})
+ {:noreply, socket}
+ end
+
+ def handle_in("ctl:invite", %{"users" => invitees}, socket) when is_list(invitees) do
+ Enum.each(invitees, fn(invitee) -> Endpoint.broadcast!("user:" <> invitee, "notify:invite", %{ user: socket.assigns.user.username, room: socket.topic}) end)
+ {:noreply, socket}
+ end
+
+ def handle_in("ctl:invite", params, socket) do
+ {:noreply, socket}
+ end
end
diff --git a/lib/kolab_chat/web/channels/system_channel.ex b/lib/kolab_chat/web/channels/system_channel.ex
index 965a022..76460b6 100644
--- a/lib/kolab_chat/web/channels/system_channel.ex
+++ b/lib/kolab_chat/web/channels/system_channel.ex
@@ -1,108 +1,110 @@
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)
end
@spec handle_info(:after_join, socket :: pid()) :: {:noreply, socket :: pid()}
def handle_info(:after_join, socket) do
push socket, "presence_state", Presence.list(socket)
- push socket, "info", %{user: socket.assigns.user.username}
+ push socket, "info", %{user: socket.assigns.user.username, userId: socket.assigns.user.id}
- Presence.track(socket, socket.assigns.user.username, %{
+ Presence.track(socket, socket.assigns.user.id, %{
+ username: socket.assigns.user.username,
status: get_user_status(socket),
context: socket.assigns.context
})
{: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)
{:noreply, socket}
end
defp perform_join(context, socket) do
socket = assign(socket, :context, context)
send self(), :after_join
{:ok, socket}
end
defp update_presence_status(socket, :invalid), do: socket
defp update_presence_status(socket, status) do
- Presence.update(socket, socket.assigns.user.username, %{
+ 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
end
end
# Get the last user/context status from the database
defp get_user_status(socket) do
require Amnesia
require Amnesia.Helper
- key = socket.assigns.user.username <> ":" <> socket.assigns.context
+ 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 = socket.assigns.user.username <> ":" <> socket.assigns.context
+ 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/channels/user_channel.ex b/lib/kolab_chat/web/channels/user_channel.ex
new file mode 100644
index 0000000..2afb2fa
--- /dev/null
+++ b/lib/kolab_chat/web/channels/user_channel.ex
@@ -0,0 +1,18 @@
+defmodule KolabChat.Web.UserChannel do
+ use KolabChat.Web, :channel
+
+ @spec join(topic :: binary(), args :: map(), socket :: pid()) :: {:ok, socket :: pid()}
+ def join("user:" <> userId, _, socket) do
+ userId = socket.assigns.user.id
+ case userId do
+ userId -> {:ok, socket}
+ _ -> {:error, socket}
+ end
+ end
+
+ @spec handle_in(topic :: binary, args :: map(), socket :: pid()) :: {:noreply, socket :: pid()}
+ def handle_in("notify:invite", message, socket) do
+ broadcast! socket, "notify:invite", message
+ {:noreply, socket}
+ end
+end
diff --git a/lib/kolab_chat/web/channels/user_socket.ex b/lib/kolab_chat/web/channels/user_socket.ex
index d1f03db..1b28c5f 100644
--- a/lib/kolab_chat/web/channels/user_socket.ex
+++ b/lib/kolab_chat/web/channels/user_socket.ex
@@ -1,41 +1,42 @@
defmodule KolabChat.Web.UserSocket do
use Phoenix.Socket
alias KolabChat.Database
## Channels
channel "room:*", KolabChat.Web.RoomChannel
+ channel "user:*", KolabChat.Web.UserChannel
channel "system", KolabChat.Web.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: 86_400) do
{:ok, user_id} ->
socket = assign(socket, :user, Database.User.read!(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/lib/kolab_chat/web/controllers/chat_controller.ex b/lib/kolab_chat/web/controllers/chat_controller.ex
index 306e007..8c3a972 100644
--- a/lib/kolab_chat/web/controllers/chat_controller.ex
+++ b/lib/kolab_chat/web/controllers/chat_controller.ex
@@ -1,17 +1,27 @@
defmodule KolabChat.Web.ChatController do
use KolabChat.Web, :controller
plug :put_layout, "chat.html"
- def index(conn, %{"room" => room} = _params) do
+ def index(conn, %{"room" => room} = params) do
+ create_room(conn, params, room)
+ end
+
+ def index(conn, params) do
+ create_room(conn, params, UUID.uuid4())
+ end
+
+ defp create_room(conn, params, roomId) do
conn
- |> assign(:room, room)
+ |> assign(:room, roomId)
+ |> assign_invitees(params)
|> render("index.html")
end
- def index(conn, _params) do
+ defp assign_invitees(conn, %{"invite" => invitees}) do
conn
- |> assign(:room, UUID.uuid4())
- |> render("index.html")
+ |> assign(:invitees, invitees)
end
+
+ defp assign_invitees(conn, _params), do: conn
end
diff --git a/lib/kolab_chat/web/templates/chat/index.html.eex b/lib/kolab_chat/web/templates/chat/index.html.eex
index 5b3e5b1..f792d39 100644
--- a/lib/kolab_chat/web/templates/chat/index.html.eex
+++ b/lib/kolab_chat/web/templates/chat/index.html.eex
@@ -1,2 +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
index 4e40305..563dcca 100644
--- a/lib/kolab_chat/web/templates/layout/chat.html.eex
+++ b/lib/kolab_chat/web/templates/layout/chat.html.eex
@@ -1,26 +1,27 @@
<%= gettext "Kolab Real Time Communication" %>
">
<%= render @view_module, @view_template, assigns %>
<%= if @conn.assigns[:user] do %>
+
<% end %>