Phoenix LiveViewで入力中のユーザーを表示するやつをつくる

# やりたいこと

他のユーザーがフォームに入力中だったら`2人が入力中...`みたいな感じで表示するやつをphoenixでつくりたい

slackとかdiscordみたいなやつ

※用語の使いまわしなどがあまり分かっていないので違和感のある表現の場合があります

実装イメージ。左のユーザーが入力すると右のユーザー（別アカウントでログイン）に表示されてます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/307103/b6ed43cb-ab2e-bf9a-988d-da8f07cb429a.png)



# 動作環境

```
$ elixir -v
Erlang/OTP 23 [erts-11.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [hipe] [dtrace]

Elixir 1.11.4 (compiled with Erlang/OTP 23)

$ mix phx.new -v      
Phoenix v1.5.10
```

# 実装方針

`Phoenix.PubSub`を使います

- 入力中の情報を更新
- 入力中の情報を一定時間で削除

に分けて説明します

入力中のユーザーの情報は以下のような`map`を`assigns`で管理して表示を更新していきます。

```elixir
%{typing_users: %{user_id => ttl}}
```

今回は入力人数を表示するのでこの形式ですが、`map`の中身を工夫すれば入力中のユーザー名の表示など改良ができそうな予感がしています。

`ttl`は`Time.add(Time.utc_now, 5)`みたいな感じで表示する期間を管理します。

## 入力中の情報を更新

1. ページ読み込み時に`Phoenix.PubSub.subscribe/2`で購読
2. フォームの入力を受け取るイベントを`handle_event/3`で受け取って`Phoenix.PubSub.broadcast/3`で送信
3. 2のイベントを`handle_info/2`で受け取って入力中の情報を`assigns`に追加
4. 更新後の`assigns`をみて表示切り替え

## 入力中の情報を一定時間で削除

1. ページ読み込み時に1秒ごとに定期実行させる`:hide_typing`イベントを設定
2. `assigns.typing_users`からTTLが未来のものをフィルター
3. 表示切り替え

# 実装

プロジェクト作成時に--liveを付けておくと予めLiveView周りの設定が完了された状態でプロジェクトが作成されます

```
mix phx.new my_app --live
```

routing、DB周り、ログイン周りは省略します

ログイン周りはphx_gen_authを使って以下のコマンドでUserを作成しています。

```
mix phx.gen.auth Users User users
```

## 1.入力中の情報を更新

### 1-1.ページ読み込み時に`Phoenix.PubSub.subscribe/2`で購読

`mount/3`（ページ読み込み時に呼ばれる）にてsubscribeを呼びます。
`"topic"`のところにその部屋のIDなどを入れることで部屋によって分けることができます。

```elixir:lib/my_app_web/live/post_live/index.ex
def mount(_params, session, socket) do
  current_user = MyApp.Users.get_user_by_session_token(session["user_token"])
  # ...
  if connected?(socket) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "topic")
  end
  # ...
  socket
  |> assign(:current_user, current_user)
  |> assign(:typing_users, %{})
end
```

### 1-2. フォームの入力を受け取るイベントを`handle_event/3`で受け取って`Phoenix.PubSub.broadcast/3`で送信

フォーム側は`phx_change`でイベントを送ります。
今回はテーブルに紐づく前提として作っているため`form_for`を使っています。
とりあえず動かしたい場合はform_forなくてもいけそうです。

```leex:lib/my_app_web/live/post_live/index.html.leex
<%= f = form_for Post.changeset(%Post{}, %{}), "#", phx_submit: "submit", phx_change: "typing" %>
  <%= textarea f, :body %>
  <%= submit "送信", phx_disable_with: "Submitting..." %>
<% end %>
```

補足：form_forを使っていると特定のtextareaに対して`phx_change`が使えないみたいです

イベント受け取り側は`handle_event/3`で受け取り`broadcast`でuser_idを含めて送信します。
ここでの`socket.assigns.current_user.id`は入力したユーザーになります。

```elixir::lib/my_app_web/live/post_live/index.ex
def handle_event("typing", _params, socket) do
  Phoenix.PubSub.broadcast(MyApp.PubSub, "topic", {:typing, socket.assigns.current_user.id})
  {:noreply, socket}
end
```

### 1-3. 2のイベントを`handle_info/2`で受け取って入力中の情報を`assigns`に追加

1-2のイベントを受け取ります。
`{:typing, socket.assigns.current_user.id}`をパターンマッチで受け取ります。
`Map.put/3`で既存の`user_id`はTTL更新して、存在しない場合は新規で作成します。

```elixir
def handle_info({:typing, user_id}, socket) do
  cond do
    # 自分の場合は表示しない
    user_id == socket.assigns.current_user.id ->
      {:noreply, socket}
    true ->
      # とりあえずTTLは5秒
      {:noreply, socket |> update(:typing_users, fn users -> Map.put(users, user_id, Time.add(Time.utc_now, 5)) end)}
  end
end
```

### 1-4. 更新後の`assigns`をみて表示切り替え

assignsは更新されると表示側にも更新がかかります。（更新の範囲とかはあまり把握していません...）
if文で`<p>タグ`の透明度を操作しています。表示の切り替えを行っても良いのですが、表示物がガタガタ動いてしまうので透明度いじってます。

```leex:lib/my_app_web/live/post_live/index.html.leex
<p style=<%= if Kernel.map_size(@typing_users) == 0, do: "opacity:0;", else: "opacity:1;" %>>
  <%= Kernel.map_size(@typing_users) %>人が入力中
</p>
```

## 2.入力中の情報を一定時間で削除

### 2-1. ページ読み込み時に1秒ごとに定期実行させる`:hide_typing`イベントを設定

チュートリアルにある`:tick`と同じような形でイベントを設定します。

```elixir:lib/my_app_web/live/post_live/index.ex
def mount(_params, session, socket) do
  # ...
  if connected?(socket) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "topic")
    :timer.send_interval(1000, self(), :hide_typing) # <- add
  end
  # ...
end
```

### 2-2. `assigns.typing_users`からTTLが未来のものをフィルター

`handle_info/2`で1-1のイベントを受け取る処理を書きます。
現在の時刻と`assigns`から取得できる時刻を比較して`map`をフィルターします。

```elixir:lib/my_app_web/live/post_live/index.ex
def handle_info(:hide_typing, socket) do
  {:noreply,
   socket
   |> update(:typing_users,
             fn users ->
               Enum.reduce(users, %{}, &(filter_active_users(&1, &2)))
             end)}
end

defp filter_active_users({user_id, time_ex}, acc) do
  cond do
    Time.compare(time_ex, Time.utc_now) == :lt ->
      Map.put(acc, user_id, time_ex)
    true ->
      acc
  end
end
```

### 2-3. 表示切り替え

1-4と同じく、assignsに更新がかかるので切り替わります

これで、入力中のユーザーの表示切り替えがリアルタイムで行うことができます。

# 所感

LiveViewとPubSubを使うことでイベントの受け取りと通知と表示切り替えがPhoenixだけで完結するのでめちゃくちゃ便利だなと思いました。

まだまだElixirの日本語の情報は少ないのでちょっとしたことでも書いていけたらと思います。