やりたいこと
他のユーザーがフォームに入力中だったら2人が入力中...
みたいな感じで表示するやつをphoenixでつくりたい
slackとかdiscordみたいなやつ
※用語の使いまわしなどがあまり分かっていないので違和感のある表現の場合があります
実装イメージ。左のユーザーが入力すると右のユーザー(別アカウントでログイン)に表示されてます。
動作環境
$ 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
で管理して表示を更新していきます。
%{typing_users: %{user_id => ttl}}
今回は入力人数を表示するのでこの形式ですが、map
の中身を工夫すれば入力中のユーザー名の表示など改良ができそうな予感がしています。
ttl
はTime.add(Time.utc_now, 5)
みたいな感じで表示する期間を管理します。
入力中の情報を更新
- ページ読み込み時に
Phoenix.PubSub.subscribe/2
で購読 - フォームの入力を受け取るイベントを
handle_event/3
で受け取ってPhoenix.PubSub.broadcast/3
で送信 - 2のイベントを
handle_info/2
で受け取って入力中の情報をassigns
に追加 - 更新後の
assigns
をみて表示切り替え
入力中の情報を一定時間で削除
- ページ読み込み時に1秒ごとに定期実行させる
:hide_typing
イベントを設定 -
assigns.typing_users
からTTLが未来のものをフィルター - 表示切り替え
実装
プロジェクト作成時に--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などを入れることで部屋によって分けることができます。
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なくてもいけそうです。
<%= 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
は入力したユーザーになります。
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更新して、存在しない場合は新規で作成します。
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>タグ
の透明度を操作しています。表示の切り替えを行っても良いのですが、表示物がガタガタ動いてしまうので透明度いじってます。
<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
と同じような形でイベントを設定します。
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
をフィルターします。
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の日本語の情報は少ないのでちょっとしたことでも書いていけたらと思います。