Building a Chat: Streams, Hooks, PubSub

DANA 325 M R O V D I S P L H B N X E C A G F K U T Q W

Objectives

not yet graded
  • (2 Points)

  • (2 Points)

  • (1 Point)

  • (1 Point)

  • (2 Points)

Prelude

Today we are building a Chat. Please play around with the example I shared with you.

To sum app our chat has the following features:

  • Join Chat (select a username)
  • Leave Chat (messages still visible)
  • Messages can be written via a bottom-fixed form
  • When typing/submitting a message the page content scrolls to the latest message
  • When submitting a message, the input field will automatically select itself again for quick retyping
  • Notifications are shown when someone enters/exists the chat.

Again this is a fantastic example of how LiveView can do ALL of this in less than 200 lines of code.

Today is more of a follow instructions type of lecture and the video is not directly related. The idea is to allow you to complete the objectives more quickly so you have additional time to revamp your page into a SPA (Single Page Application). This is however optional and not really required or even recommended. If this was a new project I would say definately go for it but since we have already created controllers, different liveviews, ets. its probably not worth the time investment unless you are planning to scuttle and scratch previous assigments as you prepare your workspace for your final project. I will leave some tips at the end of this lecture to help you reorganize your workspace

Building a Chat

If you have kept the original modal component that came with phoenix or if you use the one I have shared with you last class than you will find that to open a modal we expect a push

/path       (modal closed)
/path/new   (modal open)

The on_cancel attribute should point back to /path. If you observed the behavior of setting up a username via the join chat modal than we should get an idea for our router.ex routes:

live "/chat", ChatLive, :chat
live "/chat/join", ChatLive, :join

Drop those in an appropriate scope. We won't require authentication or anything but the regular browser pipeline unless your app.html.heex still depends on the nerfarious @message_changeset. In this case load it in a scope that runs the appropriate on_mount function.

Now its time to template our liveview

defmodule AppWeb.ChatLive do
  use AppWeb, :live_view

  # will render chat, bottom message form and the modal (if liveaction == :join)
  def render(assigns)

  # will set up some basic assigns and our message stream
  def mount(_params, _session, socket)

  # will clean up/provide :username_form depending on whether we are on join page or not
  def handle_params

  # triggered when changing the message
  def handle_event("change-message", %{"chat" => params}, socket)

  # triggered when submitting the message
  def handle_event("send-message", %{"chat" => params}, socket)

  # triggered when changing the username
  def handle_event("change-name", %{"user" => params}, socket)

  # triggered when submitting the username / joining the chat
  def handle_event("join-chat", %{"user" => params}, socket)

  # triggered when leaving the chat
  def handle_event("leave-chat", _params, socket)

  # will add the message to the stream
  # triggered at all sockets by a broadcast from any socket.
  def handle_info(message, socket)

  # some helper functions to create inline ecto changesets without schema
  defp change_message(params)
  defp change_name(params)
end

LiveView Streams

Lets read the section on streams in the LiveView documentation:

Streams are a mechanism for managing large collections on the client without keeping the resources on the server

This is big. In our example this allows us to update our chat with all the incoming messages while not maintaining them on the server thus keeping our memory footprint minimal. Streams allow us to prepend/append new messages and we can reset them or set an upper limit of maximum chat messages to display.

Lets start with our mount function:

@impl true
def mount(_params, _session, socket) do
  {:ok,
  socket
  |> stream(:messages, [])}
end

This creates a stream :messages that starts out empty. We ignored optional attributes such as limit and append messages by default instead of prepending them.

In our render function we can handle incoming messages with minimal code:

<ul id="messages" phx-update="stream">
  <li
    :for={{dom_id, message} <- @streams.messages}
    id={dom_id}
  >
    ...
  </li>
</ul>

New messages will automatically added as list items when they become available. Later we are going to Flowbite styling but I recommend leaving this task till the end and first ensure messages are correctly formatted and appear in your list items.

I did use Flowbites Chat Bubbles and stripped them of Avatars, Dropdowns and timestamps.

Lets now see how we can append a new message. The message will come in via the handle_info function:

@impl true
def handle_info(message, socket) do
  {:noreply, stream(socket, :messages, [message])}
end

This one-liner will update the stream and append the message as it arrives. Remember that handle_info can be triggered in many ways such as:

  • a component / the liveview sent an update
  • another socket sent a message via a communication system that we will learn about today: PubSub

This implies that a single socket can trigger the handle_info function on multiple sockets (everyone who is currently on the chat page).

Subscribing to a channel

Pubsub allows us to subscripe our channels to rooms and send messages to every member of a given room:

# subscribes the current socket (process) to the room `chat`
Phoenix.PubSub.subscribe(App.PubSub, "chat")

# sends a message to every socket in the room `chat`:
Phoenix.PubSub.broadcast(App.PubSub, "chat", message)

You can read more about PubSub in the Phoenix Documentation but those are all that we need. In future its also practical to know about the unsubscribe method, in case we need to leave a group before the socket terminates naturally.

Considering that we will only have one single chat room on our page, discuss with your partner, what function is most appropriate to host the subscribe function and place it inside.

Hopefully you also suspect that the broadcast will have to happen inside:

# triggered when submitting the username / joining the chat
def handle_event("join-chat", %{"user" => params}, socket) do
  # broadcast somewhere here
end

But we'll come back to this.

Join Modal

Lets now shift our attention to the join modal.

Our User Interface suggests a bottom bar with 1 or 3 elements depending on the status of :username:

  • is_nil(username): [ Join Chat ]
  • not is_nil(username): [ Leave Chat | Message Field | Send Message ]

Go ahead, you can initialize :username to be nil in the mount function.

Remember the button to open a modal will look like this:

<.button :if={!@username} patch={~p"/chat/join"}>Join Chat</.button>

If you don't use my button that i shared with you last Friday you will likely get a warning that it doesn't understand patch. In this case you can implement it as a <.link> element.

Now we also need a modal in the render function:

<.modal
  :if={@live_action == :join}
  id="join-modal"
  show
  heading="Join Chat"
  backdrop="static"
  on_cancel={JS.patch(~p"/chat")}
>
  Message Form
</.modal>

You should now be able to open / close the modal via the Join / the x button in the modal.

We will need to have a <.form> that allows us to change a username. But we don't want to bother creating an Ecto schema, especially because we are not planning to persist chat messages. Ecto allows you to create a changeset (including validation) directly from a map:

@types %{username: :string}
defp change_username(params) do
  {%{}, @types}
  |> Ecto.Changeset.cast(params, Map.keys(@types))
  |> Ecto.Changeset.validate_required(:username)
  |> Ecto.Changeset.validate_length(:username, max: 16)
end

Notice how the cast/4 function takes in a 2-element tuple instead of a schema struct as the first argument. We are starting with an empty map and telling ecto that we expect 1 field (:username) that is required and has a max length of 16 characters.

Go ahead and create the change_message/1 function in a very similar way.

In your mount/3 function we also want to assign a message form and set the username to nil, indicating that initially we haven't joined the chat:

socket
|> assign(:form, to_form(change_message(%{}), as: :chat))
|> assign(:username, nil)

We can now add the bottom bar (with form and login/logout buttons) and shouldn't experience any missing assigns:

<div class="fixed bottom-0 left-0 right-0 bg-gray-100 h-auto justify-center dark:bg-gray-800 flex">
  <.button :if={!@username} patch={~p"/chat/join"}>Join Chat</.button>

  <.form
    :if={@username}
    class="p-6 sm:flex gap-4 w-full items-end"
    for={@form}
    phx-change="change-message"
    phx-submit="send-message"
  >
    <.button phx-click="leave-chat">Leave Chat</.button>
    <.input field={@form[:message]} placeholder="Type Message" wrapper_class="sm:flex-1" />
    <div>
      <.button type="submit" class="w-full">Send Message</.button>
    </div>
  </.form>
</div>

Notice how the fixed class in combination with bottom-0 locks the form to the bottom of the screen. I did add pb-20 to my <ul> classes to ensure that messages won't end up below the bottom bar.

To complete the render function we still need a second form inside the modal for the @username_form. I will leave the structure and styling up to you but help you out with the handle_params function:

@impl true
def handle_params(_unsigned_params, _uri, socket) do
  case socket.assigns.live_action do
    :chat ->
      {:noreply, assign(socket, :username_form, nil)}

    :join ->
      {:noreply, assign(socket, :username_form, to_form(change_username(%{}), as: :user))}
  end
end

Remember that the handle_params/3 function always gets called on a patch, navigate and redirect. We only need the username_form when the @live_action == :join, thus it doesn't make sense to keep it on the chat page. I can dynamically nilify unneeded assigns for a given live_action this way via the handle_params/3 function.

Most often handle_params/3 is used to load database records based on some url param such as planet based on a planet_id: /planets/:id.

Changing Forms

Lets have a look at how we allow changes to our username_form:

def handle_event("change-name", %{"user" => params}, socket) do
  changeset = params |> change_username() |> Map.put(:action, :validate)
  {:noreply, assign(socket, :username_form, to_form(changeset, as: :user))}
end

I am really only calling my changeset on the params and adding an action (:validate) so that liveview can display fancy error messages. Here is again my reminder that you will want my <input> component from last lecture for this to show.

Go Ahead and change :form (message form) in a similar fashion.

When it comes to joining the chat (submitting the username form) we want to:

  • only allow valid usernames to join
  • sending a message with the username to everyone in the chat (event type: join)
  • update our socket with the username
  • close the modal

Here is my function that achieves all of this:

def handle_event("join-chat", %{"user" => params}, socket) do
  changeset = change_username(params)

  if changeset.valid? do
    username = Ecto.Changeset.get_change(changeset, :username)

    message = %{
      id: Ecto.UUID.generate(),
      username: username,
      type: :user_joined
    }

    Phoenix.PubSub.broadcast(App.PubSub, "chat", message)

    {:noreply,
    socket
    |> assign(:username, username)
    |> push_patch(to: ~p"/chat")}
  else
    {:noreply, assign(socket, :username_form, to_form(changeset, as: :user))}
  end
end

Notice here that message is a map with an :id field. While we can send any form of message via PubSub, our stream can only accept either real structs or maps with an id.

  • If we send a map with a new id it gets appended in the stream on the page.
  • If we send a map with an existing id it updates the entry in the stream on the page.

To ensure all ids are unique I generated a unique binary_id via Ecto.UUID.generate().

Go ahead, you should now be able to complete the leave function:

def handle_event("leave-chat", _params, socket) do
  # TODO
end

Jobs it should fullfill:

  • send a message (of type :user_left) to the chatroom that :username left.
  • set the socket's username back to nil

The final piece related to forms is the "save-message" handler:

def handle_event(
    "send-message",
    %{"chat" => params},
    %{assigns: %{username: username}} = socket
  )
  when not is_nil(username) do
  changeset = change_message(params)

  if changeset.valid? do
    message = %{
      id: Ecto.UUID.generate(),
      username: socket.assigns.username,
      message: Ecto.Changeset.get_change(changeset, :message),
      type: :message
    }

    Phoenix.PubSub.broadcast(App.PubSub, "chat", message)

    {:noreply, assign(socket, :form, to_form(change_message(%{}), as: :chat))}
  else
    {:noreply, assign(socket, :form, to_form(changeset, as: :chat))}
  end
end

Notice when not is_nil(username) in the function header. This is an example of a quick and effective way to protect a event from missuse by a hacker via what is called a guard. In this case we only want to be able to send messages if we have an actual username. Otherwise the page could break for every participant.

You should be able to understand all parts of this function now. Move on, once you do.

UX (User Experience)

To make the chatroom experience a little more fluid I considered two ux features:

  • Chat should autoscroll down to last message on typing and submitting
  • Message Field should remain focused as we submit messages for rapid texting

The second option is surprisingly easy:

<li ... phx-mounted={JS.push_focus(to: "#chat_message")} />

Add the phx-mounted attribute to your <li> item. This instructs LiveView to focus the #chat_message form field everytime a message is added to the stream.

The first UX concern is a bit trickier and requires a phx-hook.

Lets start with adding a new hook to our app.js file:

let Hooks = {};

Hooks.AutoScroll = {
  updated() {
    window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
  }
};

Then update LiveSocket to use your Hooks:

let liveSocket = new LiveSocket(liveSocketPath, Socket, {
  // longPollFallbackMs: 2500,
  params: { _csrf_token: csrfToken },
  hooks: Hooks
})

We can now attach phx-hook="AutoScroll" to any element and once that element gets updated we will scoll to the bottom of the page via the window.scrollTo function call. We have done this on the <ul> container:

<ul id="messages" phx-update="stream" class="space-y-4 pb-20" phx-hook="AutoScroll">
  ...
</ul>

This contains all the elements needed for the chat and hopefully you can piece everything together from here. Cheers!

Cleaning up workspace

If you do no longer need your old controllers/routes/contexts/views you may want to archive them at this point.

Of course you could also just always pull from an older git commit. I found it convenient though to have things readily available in my code base. The procedure I generally recommend is:

  • Start by commenting out unneeded routes/scopes in the router. You will get tons of warnings that routes cannot be found if you used the ~p sigil in your templates as you should have.
  • Back up your app.html.heex file. Chances are you will have to completely revamp it but its good to be able to go back to the original.
  • rename unneeded files by appending .tmp at the end. For example: planets.ex --> planets.ex.tmp. This procedure works for all .ex files (such as controllers, contexts, schemas, views) as well as tests and heex files. You can configure VSCode to still treat .ex.tmp files as elixir files for the purpose of syntax highlighting.
  • do not remove/comment out UI components as you never know if you need them again
  • do not comment out/remove migration files. leave them untouched.
  • If you now have warnings/errors remaining resolve on a case by case basis. Failing Tests can now be commented out as a whole.
Copyright © 2025 Alexander Fuchsberger, Bucknell University. All rights reserved.