Build a small chat service using Elixir and deploy it on Amazon ec2 using AWS (Part 2)
Author: Tam Ly
Published: 2024-09-14

Introduction

At Part 1, we explored how to implement the database for our chat service. In this article, we’ll dive into how to implement chat functionality using LiveView, LiveComponent, PubSub, and Channels.

Channel

In Endpoint.ex, we will define the socket handler that will manage connections on a specified URL.

  socket "/live", ChatServiceWeb.UserSocket, websocket: true

  socket "/api/socket", ChatServiceWeb.UserSocket,
    websocket: true,
    longpoll: false

On the client side, you will establish a socket connection to the route above, the url of application will be like this: http://localhost:8080/chat_room?user_id=jack&channel_id=game_1

with user_id is your id and channel_id is name of room.

const userId = params.get('user_id');
const channelId = params.get('channel_id');

let channel = liveSocket.channel("channel:" + channelId, {params: {user_id: userId}})

channel.join()
  .receive("ok", resp => { console.log("Joined successfully", resp) })
  .receive("error", resp => { console.log("Unable to join", resp) })

On the server, Phoenix will call ChatServiceWeb.UserSocket.connect/2, passing your parameters along with the initial socket state. Within this function, you can authenticate and identify the socket connection, as well as set default socket assigns. The socket is also where you define your channel routes.

The asterisk * serves as a wildcard matcher. For example, in the following channel route, requests for channel:channel_id will all be directed to the game_channel.ex. In your UserSocket, you would have:

defmodule ChatServiceWeb.UserSocket do
  use Phoenix.LiveView.Socket

  channel "lv:*", Phoenix.LiveView.Channel
  channel "channel:*", ChatServiceWeb.GameChannel

  @impl true
  def connect(_params, socket, _connect_info) do
    {:ok, socket}
  end

  @impl true
  def id(_socket), do: nil
end

In game_channel.ex, we will implement the join/3 callback, allowing anyone to join the room using the channel_id. After joining the channel, the user will be redirected to the LiveView page, where we’ll now discuss LiveView and LiveComponent.

defmodule ChatServiceWeb.GameChannel do
  use Phoenix.Channel
  alias Phoenix.PubSub
  alias ChatService.Room.RoomActions
  alias Phoenix.LiveView.Socket

  use Phoenix.LiveView

  @pubsub_chat_room Application.compile_env(:chat_service,
                                            [:pubsub, :chat_room])

  @impl true
  def join("channel:" <> channel_id,
           %{"params" => payload},
           %Phoenix.Socket{transport_pid: transport_pid} = socket) do

    user_id = Map.get(payload, "user_id", :not_found)
    is_embedded = Map.get(payload, "is_embedded", :not_found)
    if authorized?(user_id, payload) do
      topic = "channel:" <> channel_id
      if connected?(%Socket{transport_pid: transport_pid}), do:
        PubSub.broadcast(@pubsub_chat_room, topic, {:join_room,
                                                    %{channel_id: channel_id,
                                                      is_embedded: is_embedded,
                                                      user_id: user_id}})
      {:ok, socket}
    else
      {:error, %{reason: "unauthorized"}}
    end
  end

However, when game_channel.ex communicates with LiveView, it needs to use PubSub to transfer messages between them. In the ChatRoomLiveView module, you’ll need to subscribe to the PubSub topic within the mount function.

  def mount(params, session, socket) do
    {channel_id, user_id} =
      {Map.get(params, "channel_id", :not_found),
       Map.get(params, "user_id", :not_found)}

    topic = "channel:" <> channel_id
    :ok = PubSub.subscribe(@pubsub_chat_room, topic)
...
end

Using this Pubsub, whenever we need to send message from channel to liveview, we just use PubSub.broadcast/3.

We will design simple chat service page like this, this is using liveview page. Image description

There are two main components: the discussion area on the left side and the list of current users online for the room on the right side. Everything else is part of the LiveView page.

Image description

Whenever a user joins the room or sends a message, the LiveView page receives the message from the channel via PubSub, updates the message history in the message component, and refreshes the list of online users.

For example, when a user sends a new message, we handle the keypress event and use channel.push to send the message from JavaScript (app.js) to the channel:

let chatInput = document.querySelector("#chat-input")

chatInput.addEventListener("keypress", event => {
    if(event.key === 'Enter' && chatInput.value != ""){
        channel.push("new_msg", {body: chatInput.value, user_id: userId})
      chatInput.value = ""
    }
  })

In game_channel.ex, we will implement the handle_in/3 callback to receive new messages from the client side, along with the channel_id as the topic. The new message will be saved, associating it with the user, channel_id, and message body. We use the spawn function to run the save_message function asynchronously, as we don’t need to wait for it to complete.

  @impl true
  def handle_in("new_msg", %{"body" => body, "user_id" => user_id},
                %Phoenix.Socket{transport_pid: transport_pid,
                                topic: "channel:" <> channel_id} = socket) do
    if connected?(%Socket{transport_pid: transport_pid}) do
      topic = "channel:" <> channel_id
      spawn(fn -> save_message(channel_id, body, user_id) end)
      PubSub.broadcast(@pubsub_chat_room, topic, {:send_msg,
                                                  %{body: body,
                                                    user_id: user_id,
                                                    channel_id: channel_id}})
     end
    {:noreply, socket}
  end

  # save message into database.
  defp save_message(channel_id, body, user_id) do
    [room_id] = RoomActions.get_room_id_of_channel(channel_id)
    {:ok, _} = RoomActions.add_chat(%{message: body, user_id: user_id,
                                      room_id: room_id})
  end

On the LiveView page, we will use the handle_info/2 callback to capture messages from the channel and then update the message history accordingly.

  @impl true
  def handle_info({:send_msg,
                   %{body: body, user_id: user_id}},
                   %{assigns: %{message: old_msg}} = socket) do
    new_msg = old_msg ++ [%{messages: body, user_name: user_id}]
    # it will send update message to ChatServiceWeb.MessagesComponent
    # because we assign new :message for socket (socket is changed)
    {:noreply, assign(socket, :message, new_msg)}
  end

At message live componnent page, we just need to assign message to socket.

  @impl true
  def update(%{message: message}, socket) do
    {:ok, assign(socket, :message, message)}
  end

Finally, when we run command iex -S mix phx.server, and we can access url http://localhost:8080/chat_room?user_id=jack&channel_id=game_1 in browser, and chat something with your friend.

Next part, we will discover how to run the application in EC2 Amazon AWS (Part 3).

Repo: https://github.com/ohhi-vn/embedded_chat