Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .iex.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alias PhoenixChat.{Repo, User, AnonymousUser, Message}
2 changes: 2 additions & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ defmodule PhoenixChat.Mixfile do
applications: [
:comeonin,
:cowboy,
:faker,
:gettext,
:logger,
:phoenix,
Expand All @@ -45,6 +46,7 @@ defmodule PhoenixChat.Mixfile do
{:comeonin, "~> 2.3"},
{:corsica, "~> 0.4"},
{:cowboy, "~> 1.0"},
{:faker, "~> 0.7"},
{:gettext, "~> 0.11"},
{:guardian, "~> 0.10"},
{:phoenix, "~> 1.2.0"},
Expand Down
1 change: 1 addition & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"db_connection": {:hex, :db_connection, "1.0.0-rc.5", "1d9ab6e01387bdf2de7a16c56866971f7c2f75aea7c69cae2a0346e4b537ae0d", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: true]}, {:sbroker, "~> 1.0.0-beta.3", [hex: :sbroker, optional: true]}]},
"decimal": {:hex, :decimal, "1.1.2", "79a769d4657b2d537b51ef3c02d29ab7141d2b486b516c109642d453ee08e00c", [:mix], []},
"ecto": {:hex, :ecto, "2.0.5", "7f4c79ac41ffba1a4c032b69d7045489f0069c256de606523c65d9f8188e502d", [:mix], [{:db_connection, "~> 1.0-rc.4", [hex: :db_connection, optional: true]}, {:decimal, "~> 1.1.2 or ~> 1.2", [hex: :decimal, optional: false]}, {:mariaex, "~> 0.7.7", [hex: :mariaex, optional: true]}, {:poison, "~> 1.5 or ~> 2.0", [hex: :poison, optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: false]}, {:postgrex, "~> 0.12.0", [hex: :postgrex, optional: true]}, {:sbroker, "~> 1.0-beta", [hex: :sbroker, optional: true]}]},
"faker": {:hex, :faker, "0.7.0", "2c42deeac7be717173c78c77fb3edc749fb5d5e460e33d01fe592ae99acc2f0d", [:mix], []},
"fs": {:hex, :fs, "0.9.2", "ed17036c26c3f70ac49781ed9220a50c36775c6ca2cf8182d123b6566e49ec59", [:rebar], []},
"gettext": {:hex, :gettext, "0.11.0", "80c1dd42d270482418fa158ec5ba073d2980e3718bacad86f3d4ad71d5667679", [:mix], []},
"guardian": {:hex, :guardian, "0.12.0", "ab1f0a1ab0cd8f4f9c8cca6e28d61136ca682684cf0f82e55a50e8061be7575a", [:mix], [{:jose, "~> 1.6", [hex: :jose, optional: false]}, {:plug, "~> 1.0", [hex: :plug, optional: false]}, {:poison, ">= 1.3.0", [hex: :poison, optional: false]}, {:uuid, ">=1.1.1", [hex: :uuid, optional: false]}]},
Expand Down
16 changes: 16 additions & 0 deletions priv/repo/migrations/20160915111446_create_anonymous_users.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
defmodule PhoenixChat.Repo.Migrations.CreateAnonymousUsers do
use Ecto.Migration

def change do
# We want to use a `uuid` as primary key so we need to set `primary_key: false`.
create table(:anonymous_users, primary_key: false) do
# We add the `:id` column manually with a type of `uuid` and set
# it as `primary_key`.
add :id, :uuid, primary_key: true
add :name, :string
add :avatar, :string

timestamps
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
defmodule PhoenixChat.Repo.Migrations.MessageBelongsToAnonymousUser do
use Ecto.Migration

def up do
alter table(:messages) do
# We need to set `type` as `uuid` so it does not default to `integer`.
add :anonymous_user_id, references(:anonymous_users, on_delete: :nilify_all, type: :uuid)
remove :from
end
end

def down do
alter table(:messages) do
remove :anonymous_user_id
add :from, :string
end
end
end
56 changes: 49 additions & 7 deletions web/channels/admin_channel.ex
Original file line number Diff line number Diff line change
@@ -1,33 +1,75 @@
defmodule PhoenixChat.AdminChannel do
@moduledoc """
The channel used to give the administrator access to all users.
The channel used to give the administrator access to all users.
"""

use PhoenixChat.Web, :channel
require Logger

alias PhoenixChat.{Presence}
alias PhoenixChat.{Presence, Repo, AnonymousUser}

intercept ~w(lobby_list)

@doc """
The `admin:active_users` topic is how we identify all users currently using the app.
"""
def join("admin:active_users", payload, socket) do
authorize(payload, fn ->
send(self, :after_join)
{:ok, socket}
id = socket.assigns[:uuid] || socket.assigns[:user_id]
lobby_list = AnonymousUser.recently_active_users
|> Repo.all
{:ok, %{id: id, lobby_list: lobby_list}, socket}
end)
end

@doc """
This handles the `:after_join` event and tracks the presence of the socket that has subscribed to the `admin:active_users` topic.
This handles the `:after_join` event and tracks the presence of the socket that
has subscribed to the `admin:active_users` topic.
"""
def handle_info(:after_join, socket) do
# We no longer track admin's presence. This will lead to simpler code in the
# frontend by not having to filter out admin from the list of users in the Sidebar.
def handle_info(:after_join, %{assigns: %{user_id: _user_id}} = socket) do
push socket, "presence_state", Presence.list(socket)
{:noreply, socket}
end

# We track only the presence of anonymous users and ensure they are stored
# in our DB.
def handle_info(:after_join, %{assigns: %{uuid: uuid}} = socket) do
# We save anonymous user to DB when it hasn't been saved before
user = ensure_user_saved!(uuid)

# Used by the frontend to update their lobby_list (chatrooms displayed
# on the sidebar)
broadcast! socket, "lobby_list", user

push socket, "presence_state", Presence.list(socket)
Logger.debug "Presence for socket: #{inspect socket}"
id = socket.assigns.user_id || socket.assigns.uuid
{:ok, _} = Presence.track(socket, id, %{
{:ok, _} = Presence.track(socket, uuid, %{
online_at: inspect(System.system_time(:seconds))
})
{:noreply, socket}
end

@doc """
Sends the lobby_list only to admins
"""
def handle_out("lobby_list", payload, socket) do
assigns = socket.assigns
if assigns[:user_id] do
push socket, "lobby_list", payload
end
{:noreply, socket}
end

defp ensure_user_saved!(uuid) do
user_exists = Repo.get(AnonymousUser, uuid)
if user_exists do
user_exists
else
changeset = AnonymousUser.changeset(%AnonymousUser{}, %{id: uuid})
Repo.insert!(changeset)
end
end
end
17 changes: 3 additions & 14 deletions web/channels/room_channel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,34 +9,23 @@ defmodule PhoenixChat.RoomChannel do
messages = room_id
|> Message.latest_room_messages
|> Repo.all
|> Enum.map(&message_payload/1)
|> Enum.reverse
{:ok, %{messages: messages}, socket}
end)
end

def handle_in("message", payload, socket) do
payload = payload
|> Map.put("user_id", socket.assigns.user_id)
|> Map.put("from", socket.assigns[:uuid])
|> Map.put("user_id", socket.assigns[:user_id])
|> Map.put("anonymous_user_id", socket.assigns[:uuid])
changeset = Message.changeset(%Message{}, payload)

case Repo.insert(changeset) do
{:ok, message} ->
payload = message_payload(message)
broadcast! socket, "message", payload
broadcast! socket, "message", message
{:reply, :ok, socket}
{:error, changeset} ->
{:reply, {:error, %{errors: changeset}}, socket}
end
end

defp message_payload(message) do
from = message.user_id || message.from
%{body: message.body,
timestamp: message.timestamp,
room: message.room,
from: from,
id: message.id}
end
end
20 changes: 16 additions & 4 deletions web/channels/user_socket.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,23 @@ defmodule PhoenixChat.UserSocket do
def connect(params, socket) do
user_id = params["id"]
user = user_id && Repo.get(User, user_id)
# We added this validation. More explanation below at the function def.
validate_params!(params)

socket = if user do
socket
socket
|> assign(:user_id, user_id)
|> assign(:username, user.username)
|> assign(:email, user.email)
else
# No longer add `:user_id` field to socket.assigns
socket
|> assign(:user_id, nil)
|> assign(:uuid, params["uuid"])
|> assign(:uuid, params["uuid"])
end

{:ok, socket}
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}"
Expand All @@ -51,4 +53,14 @@ defmodule PhoenixChat.UserSocket do
#
# Returning `nil` makes this socket anonymous.
def id(_socket), do: nil

@empty ["", nil]
# We want to raise an error as early as here so our code in channels will not
# have to worry about empty values for id or uuid and our frontend won't be
# be able to connect to the socket unless they provide an id or uuid.
defp validate_params!(%{"id" => id, "uuid" => uuid}) when id in @empty or uuid in @empty do
raise "id or uuid must not be empty"
end

defp validate_params!(_), do: nil #noop
end
52 changes: 52 additions & 0 deletions web/models/anonymous_user.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
defmodule PhoenixChat.AnonymousUser do
use PhoenixChat.Web, :model

alias PhoenixChat.Message

# Since we provide the `id` for our AnonymousUser record, we will need to set
# the primary key to not autogenerate it.
@primary_key {:id, :binary_id, autogenerate: false}
# We need to set `@foreign_key_type` below since it defaults to `:integer`.
# We are using a UUID as `id` so we need to set type as `:binary_id`.
@foreign_key_type :binary_id
@derive {Poison.Encoder, only: ~w(id name avatar)a}

schema "anonymous_users" do
field :name
field :avatar
has_many :messages, Message

timestamps
end

def changeset(model, params \\ :empty) do
model
|> cast(params, ~w(id), ~w())
|> put_avatar
|> put_name
end

@doc """
This query returns users with the most recent message sent up to a given limit.
"""
def recently_active_users(limit \\ 20) do
from u in __MODULE__,
left_join: m in Message, on: m.anonymous_user_id == u.id,
limit: ^limit,
distinct: u.id,
order_by: [desc: u.inserted_at, desc: m.inserted_at]
end

# Set a fake name for our anonymous user every time we create one
defp put_name(changeset) do
name = (Faker.Color.fancy_name <> " " <> Faker.Company.buzzword()) |> String.downcase
changeset
|> put_change(:name, name)
end

# Set a fake avatar for our anonymous user every time we create one
defp put_avatar(changeset) do
changeset
|> put_change(:avatar, Faker.Avatar.image_url(25, 25))
end
end
21 changes: 18 additions & 3 deletions web/models/message.ex
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
defmodule PhoenixChat.Message do
use PhoenixChat.Web, :model

alias PhoenixChat.{DateTime}

# @derive is a module attribute for you to be able to customize how an
# Elixir Protocol treats a custom struct.
# In this case, we instruct the Poison.Encode protocol to only encode
# certain fields and ignore the rest.
# More info at:
# - https://github.com/devinus/poison#encoding-only-some-attributes
#
# This replaces the message_payload/1 function in RoomChannel
@derive {Poison.Encoder, only: ~w(id body timestamp room user_id anonymous_user_id)a}

schema "messages" do
field :body, :string
field :timestamp, PhoenixChat.DateTime
field :timestamp, DateTime
field :room, :string
field :from, :string

belongs_to :user, PhoenixChat.User
# Note that we set `:type` below. This is so Ecto is aware the type of the
# foreign_key is not an `:integer` but a `:binary_id`.
belongs_to :anonymous_user, PhoenixChat.AnonymousUser, type: :binary_id

timestamps
end

@required_fields ~w(body timestamp room)
@optional_fields ~w(user_id from)
@optional_fields ~w(anonymous_user_id user_id)

@doc """
Creates a changeset based on the `model` and `params`.
Expand Down