作って学ぶPhoenix LiveView、チャットアプリの作成

Phoenix LiveViewを学ぶためにチャットアプリを作ったので紹介します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/369713/e6c0698e-e1f5-607d-c718-4b0bd2b9b1bc.png" width="80%">
ソース https://gitlab.com/pojiro/tombo-chat/tree/qiita_liveview_article

左側ペインのRoomsの部屋を選択すると、右側ペインのMessagesとMembersがその部屋のものになるというシンプルな作りです。
※画面レイアウトは「[Build a chat app with Vue](https://qiita.com/rana_kualu/items/915345b8f3f870cfe2aa#build-a-chat-app-with-vue)」のように作りました。
※CSSフレームワークは[Bulma](https://bulma.io/)を使っています。

# LiveView機能の使用／未使用一覧

このチャットアプリは以下の機能を使用しています。
各機能について使用箇所とその使い方を以降で説明していきます。

|FEATURES/BINDING|ATTRIBUTES|使用|
|---|---|---|
|live macro||×|
|live_component, live_render||○|
|Params|phx-value-*|×|
|Click Events|phx-click|○|
|Focus/Blur Events|phx-blur, phx-focus, phx-target|×|
|Form Events|phx-change, phx-submit, data-phx-error-for, phx-disable-with|○|
|Key Events|phx-keydown, phx-keyup, phx-target|×|
|Rate Limiting|phx-debounce, phx-throttle|×|
|Custom DOM Patching|phx-update|○|
|Temporary assigns||○|
|JS Interop|phx-hook|○|
|Live navigation||×|
|Presence||○|

# ページの表示（live_render）

live_renderを使って、LiveViewモジュールをレンダリングできます。

connの値等はsessionを介して、LiveViewのモジュールに渡すことができます。
以下ではcurrent_user（ログインユーザー）を渡しています。

~~~ index.html.eex
<div>
  <%= live_render(@conn, TomboChatWeb.Rooms,
   session: %{"current_user" => @conn.assigns.current_user}) %>
</div>
~~~

# チャットルームの表示（live_render）／選択（phx-click）

live_renderの第二引数で渡したTomboChatWeb.Roomsモジュールが以下です。

~~~ rooms.ex
defmodule TomboChatWeb.Rooms do
  use Phoenix.LiveView
  use Phoenix.HTML

  alias TomboChat.Spaces

  def render(assigns) do
    ~L"""
    <div class="columns">
      <div class="column is-2">
      <p class="title is-4">Rooms</p>
      <hr>
      <ul class="menu-list">
        <%= for room <- @rooms do %>
          <li phx-click="room_id_<%= room.id %>">
            <%= room_name_tag(@room, room) %>
          </li>
        <% end %>
      </ul>
      </div>
      <div class="column is-10">
        <%= render_room(@socket, @room) %>
      </div>
    </div>
    """
  end

  defp room_name_tag(room, room), do: content_tag(:a, "# #{room.name}", class: "is-active")
  defp room_name_tag(_, room), do: content_tag(:a, "# #{room.name}")

  defp render_room(socket, room) do
    live_render(socket, TomboChatWeb.Room,
      id: room.id,　# live_renderはidを変更しないと再マウントしない
      session: %{"room" => room, "current_user" => socket.assigns.current_user})
  end

  def mount(_params, session, socket) do
    {:ok,
      assign(socket,
        rooms: Spaces.list_rooms(),
        room: Spaces.get_room_by(%{roomname: "lobby"}),
        current_user: session["current_user"]
      )
    }
  end

  def handle_event("room_id_" <> id, _params, %{assigns: _assigns} = socket) do
    {:noreply, assign(socket, room: Spaces.get_room(String.to_integer(id)))}
  end
end
~~~

## チャットルームの表示

1. LiveViewモジュールはまずmountを実行し、その後renderを実行します。
2. render_roomでLiveViewのモジュール内からさらにLiveViewモジュールを呼び出し、  
  キャプチャの右ペイン（Messages, Members）を表示しています。  

~~~ rooms.ex
      <div class="column is-10">
        <%= render_room(@socket, @room) %>
      </div>
~~~

live_renderでレンダリング対象を切り替える場合の注意点は「live_renderはidを変更しないと再マウントしない」です。
一意となるidを付与する必要があります。

~~~ rooms.ex
  defp render_room(socket, room) do
    live_render(socket, TomboChatWeb.Room,
      id: room.id, # live_renderはidを変更しないと再マウントしない
      session: %{"room" => room, "current_user" => socket.assigns.current_user})
  end
~~~

## チャットルームの選択

チャットルーム名にはphx-clickがバインドされており、クライアント（ブラウザ）でクリックするとイベントが発生します。
これをサーバーのhandle_eventで受け取ります。メッセージを投げて、受け取るように書くことができます。

クライアントでクリック

~~~ rooms.ex
        <%= for room <- @rooms do %>
          <li phx-click="room_id_<%= room.id %>">
            <%= room_name_tag(@room, room) %>
          </li>
        <% end %>
~~~

サーバーでイベントハンドリング

~~~ rooms.ex
  def handle_event("room_id_" <> id, _params, %{assigns: _assigns} = socket) do
    {:noreply, assign(socket, room: Spaces.get_room(String.to_integer(id)))}
  end
~~~

socketはステートの保管場所として使います。
assign(socket, room: Spaces.get_room(String.to_integer(id)))でクリックしたチャットルームに更新します。

### classの動的な付与

アクティブになったチャットルーム名にはclassを付与して表示を変更しています。
タグを生成する関数をroom_name_tagとして切り出し、パターンマッチで条件分岐を行うことでifを使わずシンプルにできました。

~~~ rooms.ex
          <li phx-click="room_id_<%= room.id %>">
            <%= room_name_tag(@room, room) %>
          </li>
~~~

~~~ rooms.ex
  defp room_name_tag(room, room), do: content_tag(:a, "# #{room.name}", class: "is-active")
  defp room_name_tag(_, room), do: content_tag(:a, "# #{room.name}")
~~~

# チャットルームの実装

render_roomでレンダリングするチャットルームのLiveViewモジュールが以下です。

~~~ room.ex
defmodule TomboChatWeb.Room do
  use Phoenix.LiveView
  use Phoenix.HTML

  import TomboChatWeb.ErrorHelpers, only: [error_tag: 2]

  alias TomboChat.Spaces
  alias TomboChat.Messages

  alias TomboChatWeb.Presence
  alias TomboChatWeb.Endpoint

  # render receive socket.assigns
  def render(assigns) do
    ~L"""
      <div class="columns">
        <div class="column is-10">
          <p class="title is-4">Messages@<%= @room.name %></p>
          <div id="messages" phx-update="append" phx-hook="RoomMessages" style="height: 360px; overflow: auto;">
            <%= for m <- @messages do %>
            <div id="message-<%= m.id %>" class="message" style="margin: 0em 0em 0.5em 0em;">
            <div class="message-body">
              <p><span>@<%= m.user.name %>: <%= m.body %></span></p>
              <p style="float: right;">
                <time datetime="<%= shift_naive_datetime(m.inserted_at) %>"><%= shift_naive_datetime(m.inserted_at) %></time>
              </p>
            </div>
            </div>
            <% end %>
          </div>
          <div>
            <%= form_for @message_changeset, "#", [phx_submit: :submit], fn f -> %>
              <%= textarea f, :body, placeholder: "Enter Message", class: "textarea", rows: "1" %>
              <%= error_tag f, :body %>
              <%= submit "Submit", class: "button is-success", style: "float: right;" %>
            <% end %>
          </div>
        </div>
        <div class="column is-2">
          <p class="title is-4">Members</p>
          <hr>
          <div class="list">
            <%= for user <- @users do %>
              <p class="list-item">
                @<%= user.name %>(<%= user.count %>)
              </p>
            <% end %>
          </div>
        </div>
      </div>
    """
  end

  defp topic(room_id), do: "room:#{room_id}"
  defp list_presence(topic) do
    Presence.list(topic)
    |> Enum.map(
         fn {_user_id, %{metas: list, user: user} = _data} ->
           %{name: user.name, count: Enum.count(list)}
         end
       )
  end

  def shift_naive_datetime(%NaiveDateTime{} = datetime) do
    datetime
    |> NaiveDateTime.add(3600*9)
    |> NaiveDateTime.to_string()
  end

  def mount(
        _params,
        %{"room" => room, "current_user" => current_user} = _session,
        socket) do
    TomboChatWeb.Endpoint.subscribe(topic(room.id))
    {:ok, _ref} = Presence.track(
      self(),          # pid
      topic(room.id),  # topic
      current_user.id, # key: tracked presences are grouped by key, cast as a string
      %{}              # meta
    )

    {:ok,
      assign(socket,
        room: room,
        current_user: current_user,
        message_changeset: Messages.change_message(),
        messages: Messages.list_messages(room),
        users: []
      ),
      temporary_assigns: [messages: []]}
  end

  # presenceに変化があると発行されるメッセージ("presence_diff")のコールバック
  def handle_info(
        %{event: "presence_diff"},
        %{assigns: %{room: room}} = socket) do
    {:noreply, assign(socket, users: list_presence(topic(room.id)))}
  end

  def handle_info(
        %{event: "new_message", payload: new_message,
          topic: _}, socket) do
    socket = assign(socket, messages: [new_message])
    {:noreply, socket}
  end

  def handle_event(
        "submit",
        %{"message" => message} = _payload,
        %{assigns: %{room: room, current_user: current_user}} = socket) do

    case Messages.create_message(room.id, current_user.id, message) do
      {:ok, message} ->
        Endpoint.broadcast!(
          topic(room.id), "new_message", Messages.preload(message, :user))
        {:noreply, socket}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, message_changeset: changeset)}
    end
  end

end
~~~

これはごちゃごちゃしています:sweat_smile:
シンプルにするのが今後の課題です。

## チャットメッセージの送受信（phx-submit、handle_event、broadcast、handle_info）

textareaに文字を入力し、submitボタンをクリックするとphx-submitイベントでチャットメッセージが送信されます。

~~~ room.ex
          <div>
            <%= form_for @message_changeset, "#", [phx_submit: :submit], fn f -> %>
              <%= textarea f, :body, placeholder: "Enter Message", class: "textarea", rows: "1" %>
              <%= error_tag f, :body %>
              <%= submit "Submit", class: "button is-success", style: "float: right;" %>
            <% end %>
          </div>
~~~

phx-clickと同じようにhandle_eventで受けとることができます。

~~~ room.ex
def handle_event(
        "submit",
        %{"message" => message} = _payload,
        %{assigns: %{room: room, current_user: current_user}} = socket) do

    case Messages.create_message(room.id, current_user.id, message) do
      {:ok, message} ->
        Endpoint.broadcast!(
          topic(room.id), "new_message", Messages.preload(message, :user))
        {:noreply, socket}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign(socket, message_changeset: changeset)}
    end
  end
~~~

チャットメッセージはMessages.create_message(room.id, current_user.id, message)でDBに保存した後、
topicにEndpoint.broadcast!します。

チャットルームマウント時にtopicをsubscribeしているため、

~~~ room.ex
  def mount(
        _params,
        %{"room" => room, "current_user" => current_user} = _session,
        socket) do
    TomboChatWeb.Endpoint.subscribe(topic(room.id))
    # 略
  end
~~~

チャットルームに接続しているクライアントは、これをhandle_infoで受けとることができます。

~~~ room.ex
  def handle_info(
        %{event: "new_message", payload: new_message,
          topic: _}, socket) do
    socket = assign(socket, messages: [new_message])
    {:noreply, socket}
  end
~~~

## チャットメッセージの画面への反映（phx-update、temporary_assigns）

socketにアサインされたmessagesが変化するとforで回しているmessageが全て再描画されてしまい非効率です。

~~~ room.ex
          <div id="messages" phx-update="append" phx-hook="RoomMessages" style="height: 360px; overflow: auto;">
            <%= for m <- @messages do %>
            <div id="message-<%= m.id %>" class="message" style="margin: 0em 0em 0.5em 0em;">
            <div class="message-body">
              <p><span>@<%= m.user.name %>: <%= m.body %></span></p>
              <p style="float: right;">
                <time datetime="<%= shift_naive_datetime(m.inserted_at) %>"><%= shift_naive_datetime(m.inserted_at) %></time>
              </p>
            </div>
            </div>
            <% end %>
          </div>
~~~

これを回避するためにphx-updateとtemprary_assignsを使うことができます。
マウント時にチャットルームに紐づくチャットメッセージをsocketにアサインしますが、temporary_assignsも同時に指定すると、

~~~ room.ex
  def mount(
        _params,
        %{"room" => room, "current_user" => current_user} = _session,
        socket) do
    # 略
    {:ok,
      assign(socket,
        room: room,
        current_user: current_user,
        message_changeset: Messages.change_message(),
        messages: Messages.list_messages(room),
        users: []
      ),
      temporary_assigns: [messages: []]} # ココ
  end
~~~

前節のhandle_infoで受け取ったnew_messageのみがDOMとして追加（phx-update="append"）されるようになります。

注意点はid="message-<%= m.id %>"を付与し、どこにappendすればよいか識別できるようにすることです。
※id付与を忘れてもそれっぽいエラーメッセージが出るの気付けると思います。

## チャットメッセージを受信したらスクロール（phx-hook）

ここまでJavaScriptに触れませんでしたが、ここで触れます。
messageがphx-update="append"で追加されたら、そのmessageが表示されるように自動でスクロールさせたいです。
これを実現するためにphx-hookを使います。

~~~ room.ex
          <div id="messages" phx-update="append" phx-hook="RoomMessages" style="height: 360px; overflow: auto;">
          <!--略-->
          </div>
~~~

phx-hookはDOMが変化した際に呼び出されるフックです。
使用するためには予めフック時の動作をLiveSocketを生成する際に登録しておく必要があります。

~~~ app.js
import "phoenix_html"

let Hooks = {}
Hooks.RoomMessages = {
  mounted() {
    this.el.scrollTop = this.el.scrollHeight
  },
  updated() {
    this.el.scrollTop = this.el.scrollHeight
  }
}

// Enable connecting to a LiveView socket
import {Socket} from "phoenix"
import LiveSocket from "phoenix_live_view"

let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}, hooks: Hooks})
liveSocket.connect()
~~~

これにより新規メッセージがappendされた際にupdated()が動作します。

## チャットルームにいるメンバー（[Phoenix.Presence](https://hexdocs.pm/phoenix/Phoenix.Presence.html#content)）

チャットルームにいるメンバーを表示するためにPresenceを使っています。
マウント時にPresence.tracを呼び出すことで追跡がはじまり、

~~~ room.ex
  def mount(
        _params,
        %{"room" => room, "current_user" => current_user} = _session,
        socket) do
    # 略
    {:ok, _ref} = Presence.track(
      self(),          # pid
      topic(room.id),  # topic
      current_user.id, # key: tracked presences are grouped by key, cast as a string
      %{}              # meta
    )
    # 略
  end
~~~

変化があると%{event: "presence_diff"}が発行されます。
それをhandle_infoで受け、list_presenseでsocketのusersを更新することができます。

~~~ room.ex
  # presenceに変化があると発行されるメッセージ("presence_diff")のコールバック
  def handle_info(
        %{event: "presence_diff"},
        %{assigns: %{room: room}} = socket) do
    {:noreply, assign(socket, users: list_presence(topic(room.id)))}
  end
~~~

~~~ room.ex
  defp list_presence(topic) do
    Presence.list(topic)
    |> Enum.map(
         fn {_user_id, %{metas: list, user: user} = _data} ->
           %{name: user.name, count: Enum.count(list)}
         end
       )
  end
~~~

このチャットアプリではuser_idでpresenceをグループ化しているので、
同じユーザがセッションが別のブラウザや端末でアクセスすると名前の右側の数値が接続数として表示できます。
※これはProgramming Phoenixで学びました。

# おわり

以上で、チャットアプリで使用したLiveView機能の紹介はおわりです。

## チャットアプリはLiveViewサンプルアプリの定番

かも？しれません。

作った感触としてチャットアプリはLiveViewを理解するのに良い題材でした。
以下の実装例もありLiveViewの使い方が参考になりました。

- [Tracking Users in a Chat App with LiveView, PubSub Presence](https://elixirschool.com/blog/live-view-with-presence/)
    - https://github.com/elixirschool/live-view-chat
- [LiveViewで認証付きのチャットアプリを構築する](https://www.870labo.com/posts/create-chat-app-with-liveview-part1/)
- [ElixirとPhoenixでWebSocketを使ったChatアプリケーションを作る](https://dev.classmethod.jp/server-side/chat-app-with-websocket-elixir-phoenix/)

また、LiveViewアプリケーションのサンプルは[LiveView demos, examples, and sample apps thread!](https://elixirforum.com/t/liveview-demos-examples-and-sample-apps-thread)に豊富にあり、参考にすることができます。

## LiveViewが目指す所

**Kill Your JavaScript**

は[Walk-Through of Phoenix LiveView](https://elixirschool.com/blog/phoenix-live-view/)に書かれていることですが、
実際に触ってみるとユーザー開発者に極力JavaScriptを書かさせないという意思を感じました。

Phoenix LiveViewができるかぎりJavaScriptを隠蔽するため、
ユーザー開発者はHTMLとElixirを書くだけで済むようになっています。

そうはいっても、スクロールで見たようにJavaScriptゼロはむずかしいのかなぁというのが現在の感想です。
LiveViewは発展途上なのでこれからも要チェックです。

## LiveViewアプリを作ろう！

おおざっぱですが、LiveViewの基本機能の一部を紹介しました。
この記事を読んで、これなら作れると思っていただけたら幸いです。

**「いいね」**よろしくお願いします。:wink: