How to design chat room architecture using LiveView without Channel?

Hello everyone,

Long time lurker, first time poster. I’ve learned a lot from this community already so let me begin by giving my thanks for this. My question is quite simple and high level: What is the basic architecture of a chat room using a LiveView? Why or why not should a Channel be used?

I’ve read the first two parts of Stephen Bussey’s “Real-Time Phoenix”, which greatly clarified the function and role of Socket and Channel in Phoenix, and their individual responsibilities. Having a chat application in mind, and finding the book quite motivating I decided to begin writing a prototype before finishing the book.

But I quickly became stuck due to some confusion about a few things. I have a LiveView of my Chat resource that has a name and room number (two users should be able to connect to the same example.com/chat/:room_number and chat), a User resource generated with mix.gen.auth, and a join table such that there is a many-many relationship between the two tables. According to the Phoenix docs and “Real-Time Phoenix” the best practice would be to create a Channel for the individual chat clients to connect to. But why should a Channel be created and have clients connect to it when there is already a stateful WebSocket connection to the server via the LiveView?

Should a new Channel be used in this circumstance, even though it would require adding some JS to the client? If not, then how can a subscription to the shared broadcast be maintained in the LiveView “controller” (I’m not sure if this is the right terminology)? I tried creating a new Channel, and subscribing to it within the LiveView, thus avoiding a new Socket connection.

This is a sketch of the approach I had in mind:

defmodule MyAppWeb.ChatLive.Show do
  use MyAppWeb, :live_view

  alias MyApp.Chats

  @impl true
  def mount(%{"room_number" => room_number}, _session, socket) do
    if connected?(socket) do

      # Is this the right approach or a foul hack?
      # Assume there is a basic Channel that simply passes along all messages to all who have joined, like in the tutorial.
      MyAppWeb.Endpoint.subscribe("chat:#{room_number}")
    end

    {:ok, assign(socket, :room_number, room_number, :messages, [])}
  end

  @impl true
  def handle_event("send_message", %{"message" => message}, socket) do
    MyAppWeb.Endpoint.broadcast("chat:#{socket.assigns.room_number}", "new_message", %{body: message})
    {:noreply, socket}
  end

  @impl true
  def handle_info(%{event: "new_message", payload: %{body: body}}, socket) do
    {:noreply, update(socket, :messages, fn messages -> [body | messages] end)}
  end
end

This seemed like a code smell, which is when I decided to come here for advice. I feel quite confident that with some sage guidance on high-level architecture I can avoid any major footguns and hack through the rest of the details.

Thanks,
Ipnon

1 Like

I’ve come back to this after the weekend. It is still a work in progress, but it seems a simple PubSub process shared between all of the chat LiveView client processes is the correct approach. Once there is a working prototype I’ll create a simple demo.

LLMs have changed my workflow so much, I’ve realized, that basic tasks like RTFM have gathered some dust in my toolbox. There is another thread on LLM performance with Elixir, and in my recent experience (I first learned Elixir before the current AI boom and only recently returned to writing it full time) ChatGPT very confidently will lead you in the wrong direction regarding LiveView. It has struggled immensely with creating simple forms, imports, any sort of knowledge where best practices have changed immensely in the last few years, yet when implementing functions with well-defined inputs and outputs, as is so often the case with Elixir, it’s basically flawless. The experience really highlights how Phoenix is a paradigm shift from the current mode of Python/Ruby/OOP backend and React/Native/reactive frontend: LLMs don’t learn new paradigms well, and by their nature gravitate (literally descending on a gradient) towards the tried-and-true way of doing things. An interesting thought for those most bullish on AI …

This seems to be a great example: GitHub - dwyl/phoenix-liveview-chat-example: 💬 Step-by-step tutorial creates a Chat App using Phoenix LiveView including Presence, Authentication and Style with Tailwind CSS

My own solution turned out something like this:

defmodule DemoWeb.ChatLive.Show do
  use DemoWeb, :live_view

  alias Demo.Chats

  @impl true
  def mount(%{"room_number" => room_number}, _session, socket) do
    chat = Chats.get_chat_by_room_number(room_number)
    messages = Chats.list_messages_for_chat(chat.id)

    if connected?(socket) do
      Chats.subscribe(chat.id)
    end

    {:ok,
     socket
     |> assign(:chat, chat)
     |> assign(:form, to_form(Chats.change_message(%Chats.Message{})))
     |> stream(:messages, messages)}
  end

  @impl true
  def handle_event("send", %{"message" => message_params}, socket) do
    user = socket.assigns.current_user
    chat = socket.assigns.chat

    case Chats.create_message(user, chat, message_params) do
      {:ok, message} ->
        {:noreply, assign(socket, :form, to_form(Chats.change_message(%Chats.Message{})))}

      {:error, changeset} ->
        {:noreply, assign(socket, :form, to_form(changeset))}
    end
  end

  @impl true
  def handle_event("validate", %{"message" => message_params}, socket) do
    changeset = Chats.change_message(%Chats.Message{}, message_params)

    {:noreply,
     socket
     |> assign(:form, to_form(changeset))}
  end

  @impl true
  def handle_info({:new_message, message}, socket) do
    {:noreply, stream_insert(socket, :messages, message)}
  end

    @impl true
  def render(assigns) do
    ~H"""
    <div class="flex justify-between">
      <.back navigate={~p"/chats"}>Back to chats</.back>
    </div>

    <ul id="message-list" phx-update="stream">
      <%= for {_id, message} <- @streams.messages do %>
        <li id={"message-#{message.id}"}>
          <strong><%= message.user.name %>:</strong> <%= message.content %>
        </li>
      <% end %>
    </ul>

    <.simple_form
      for={@form}
      id="message-form"
      phx-change="validate"
      phx-submit="send"
    >
      <.input field={@form[:content]} type="text" />
      <:actions>
        <.button phx-disable-with="Sending...">Send</.button>
      </:actions>
    </.simple_form>
    """
  end
end

I used the Phoenix generators to create a user resource in an accounts context, and then a chat and message resource in the chats context. Messages are associated with particular users and chats when they are created in order to display them in the right chat with the right sender. In terms of technical problems I spent a good few hours before realizing excluding phx-update="stream" from the immediate parent of the stream invocation in the template results in undefined behavior. Perhaps a compiler warning would be helpful for new users here.

defmodule Demo.Chats do
  @moduledoc """
  The Chats context for a basic messaging demo.
  """

  import Ecto.Query, warn: false
  alias Demo.Repo
  alias Demo.Chats.{Chat, Message}

  @doc """
  Returns the list of chats.
  """
  def list_chats do
    Repo.all(Chat)
  end

  @doc """
  Gets a chat by its room number.
  """
  def get_chat_by_room_number(room_number) do
    Repo.get_by(Chat, room_number: room_number)
    |> Repo.preload(:messages)
  end

  @doc """
  Returns the list of messages for a specific chat.
  """
  def list_messages_for_chat(chat_id) do
    from(m in Message,
      where: m.chat_id == ^chat_id,
      order_by: [asc: m.inserted_at],
      preload: [:user]
    )
    |> Repo.all()
  end

  @doc """
  Creates a message and broadcasts it to subscribers.
  """
  def create_message(user, chat, attrs) do
    %Message{}
    |> Message.changeset(attrs)
    |> Ecto.Changeset.put_assoc(:user, user)
    |> Ecto.Changeset.put_assoc(:chat, chat)
    |> Repo.insert()
    |> case do
         {:ok, message} ->
           broadcast_message(message)
           {:ok, message}

         {:error, changeset} ->
           {:error, changeset}
       end
  end

  @doc """
  Subscribes to chat messages.
  """
  def subscribe(chat_id) do
    Phoenix.PubSub.subscribe(Demo.PubSub, "chat:#{chat_id}")
  end

  @doc """
  Broadcasts a new message to the chat.
  """
  def broadcast_message(message) do
    Phoenix.PubSub.broadcast(Demo.PubSub, "chat:#{message.chat_id}", {:new_message, message})
  end
end

My major conceptual misunderstanding was the simplicity of info passing in Elixir. My intuition told me it couldn’t really be as simple as 3 lines, and you have a real-time distributed messaging system coordinating between a server and many clients.

# Defined in context module
Phoenix.PubSub.subscribe(Demo.PubSub, "chat:#{chat_id}")
Phoenix.PubSub.broadcast(Demo.PubSub, "chat:#{message.chat_id}", {:new_message, message})
# Defined in LiveView module
def handle_info({:new_message, message}, socket), do: {:noreply, stream_insert(socket, :messages, message)}

Phoenix has left a strong impression on me that it’s about as close as you can get to an ideal API for distributed systems. In the conventional apps I’ve been writing for many years you are ultimately trying to approach this same API in every project, but the differences in state between server and client, and the inevitable side-effect leaking allowed by OOP languages and JavaScript means you slowly diverge from this API until you have a big ball of mud.

The last week has been a learning process but I’m able to add features to my app very quickly now. I can tell this language Elixir is a labor of love, which is why although I had no replies to this thread I wanted to share what I learned with the community.

4 Likes

Also ran into this :slight_smile: . Would be great to have a complier error if it sees a @stream in the markup without an accompanying phx-update="stream"

1 Like