Closed5

【Elixir】Phoenix/LiveView でフォロー機能を実装する手順

NanaoNanao

はじめに

今回は Phoenix LiveView でユーザーをフォローする機能を作成します。
ソースコードの全体は僕の Github リポジトリにあります。

https://github.com/7oh2020/example-liveview-profile-app

要件

  • トップページ以外はログイン必須にする
  • 指定ユーザーをフォロー、フォロー解除できる
  • フォロー数・フォロワー数を更新できる。
  • フォローリスト・フォロワーリストを表示できる

準備

予め Phoenix と PostgreSQL をセットアップしておきます。
devcontainer を使うと環境構築が楽です。

プロジェクトの作成

以下の一連のコマンドで認証機能の生成まで完了します。

mix phx.new app --install
cd app
mix setup
mix phx.gen.auth Accounts User users --live
mix deps.get
mix ecto.reset

シードデータの作成

毎回ユーザーを登録するのは大変なのでシードファイルに何人かユーザーを追加しておきます。
mix ecto.resetを実行すると最後にシードファイルが読み込まれます。

/priv/repo/seeds.exs
{:ok, _} =
  App.Accounts.register_user(%{
    email: "test@example.com",
    password: "passpassword"
  })

{:ok, _} =
  App.Accounts.register_user(%{
    email: "dev@example.com",
    password: "passpassword"
  })

NanaoNanao

プロフィールページの作成

まずはプロフィールページを作成します。
/profiles/123のように user_id を指定してアクセスできるようにします。

/lib/app_web/live/profile_live.ex
defmodule AppWeb.ProfileLive do
  use AppWeb, :live_view

  alias App.Accounts

  @impl true
  def render(assigns) do
    ~H"""
    <.header>
      <h1><%= @user.email %></h1>
    </.header>
    """
  end

  @impl true
  def mount(%{"id" => id}, _session, socket) do
    {:ok, assign(socket, user: Accounts.get_user!(id))}
  end
end

続いてルーティングを編集します。
ログイン必須の scope にルートを追加します。

/lib/app_web/router.ex
  scope "/", AppWeb do
    pipe_through [:browser, :require_authenticated_user]

    live_session :require_authenticated_user,
      on_mount: [{AppWeb.UserAuth, :ensure_authenticated}] do

      # 追加
      live "/profiles/:id", ProfileLive

      live "/users/settings", UserSettingsLive, :edit
      live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
    end
  end

これでhttp://localhost:4000/profiles/123のような URL でプロフィールページへアクセスできるようになりました。

NanaoNanao

フォロー・フォロー解除機能の作成

ユーザー間のフォロー関係はリレーションシップとして扱います。
分かりやすいようにフォローする側を source、フォローされる側を target と呼んでいます。

リレーションシップの作成

以下のコマンドでリレーションシップを作成します。
コンテキスト、スキーマ、マイグレーションファイル、テストファイルが生成されます。

mix phx.gen.context Relationship UserRelationship user_relationship source_user_id:references:users target_user_id:references:users

マイグレーションファイルを編集します。
source_user_id と target_user_id はセットで一意にしたいので複合ユニーク制約にしています。

/priv/repo/migrations/xxx_create_user_relationship.exs

  def change do
    create table(:user_relationship) do
      add :source_user_id, references(:users, on_delete: :delete_all), null: false
      add :target_user_id, references(:users, on_delete: :delete_all), null: false

      timestamps(type: :utc_datetime)
    end

    create index(:user_relationship, [:source_user_id])
    create index(:user_relationship, [:target_user_id])
    create unique_index(:user_relationship, [:source_user_id, :target_user_id])
  e

スキーマファイルを編集します。
belongs_to で各 User と関連付けしています。

/
defmodule App.Relationship.UserRelationship do
  use Ecto.Schema
  import Ecto.Changeset

  schema "user_relationship" do
    belongs_to :source_user, App.Accounts.User
    belongs_to :target_user, App.Accounts.User

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(user_relationship, attrs) do
    user_relationship
    |> cast(attrs, [:source_user_id, :target_user_id])
    |> validate_required([:source_user_id, :target_user_id])
  end
end

フォロー・フォロー解除処理の作成

Relationship コンテキストを編集します。
重複チェックのために get_user_relationship 関数も作成します。データが見つからない場合に raise するものと nil を返すものの 2 パターンあると便利です。

/lib/app/relationship.ex
defmodule App.Relationship do
  @moduledoc """
  The Relationship context.
  """

  import Ecto.Query, warn: false
  alias App.Repo

  alias App.Relationship.UserRelationship

  @doc """
  条件にマッチするリレーションシップを1つ取得します。見つからない場合はnilを返します。
  """
  def get_user_relationship(source_user_id, target_user_id),
    do: Repo.one(get_relationship_query(source_user_id, target_user_id))

  @doc """
  条件にマッチするリレーションシップを1つ取得します。見つからない場合は`Ecto.NoResultsError`をraiseします。
  """
  def get_user_relationship!(source_user_id, target_user_id),
    do: Repo.one!(get_relationship_query(source_user_id, target_user_id))

  defp get_relationship_query(source_user_id, target_user_id) do
    from(
      r in UserRelationship,
      where: r.source_user_id == ^source_user_id and r.target_user_id == ^target_user_id
    )
  end

  @doc """
  リレーションシップを作成します。
  """
  def create_user_relationship(attrs \\ %{}) do
    source_user_id = Map.get(attrs, :source_user_id) || Map.get(attrs, "source_user_id")
    target_user_id = Map.get(attrs, :target_user_id) || Map.get(attrs, "target_user_id")

    %UserRelationship{}
    |> registration_change_user_relationship(source_user_id, target_user_id, attrs)
    |> Repo.insert()
  end

  @doc """
  リレーションシップを削除します。
  """
  def delete_user_relationship(%UserRelationship{} = user_relationship) do
    Repo.delete(user_relationship)
  end

  @doc false
  def registration_change_user_relationship(
        %UserRelationship{} = user_relationship,
        source_user_id,
        target_user_id,
        attrs \\ %{}
      ) do
    user_relationship
    |> UserRelationship.changeset(attrs)
    |> Ecto.Changeset.put_assoc(:source_user, App.Accounts.get_user!(source_user_id))
    |> Ecto.Changeset.put_assoc(:target_user, App.Accounts.get_user!(target_user_id))
  end
end

テストを追加します。

/test/app/relationship_test.exs
defmodule App.RelationshipTest do
  use App.DataCase

  alias App.Relationship

  describe "user_relationship" do
    alias App.Relationship.UserRelationship

    import App.RelationshipFixtures
    import App.AccountsFixtures

    test "get_user_relationship!/2 returns the user_relationship with given id" do
      u1 = user_fixture()
      u2 = user_fixture()
      rel = user_relationship_fixture(%{source_user_id: u1.id, target_user_id: u2.id})

      result = Relationship.get_user_relationship!(u1.id, u2.id)
      assert result.id == rel.id
      assert result.source_user_id == u1.id
      assert result.target_user_id == u2.id
    end

    test "create_user_relationship/1 with valid data creates a user_relationship" do
      u1 = user_fixture()
      u2 = user_fixture()

      valid_attrs = %{
        source_user_id: u1.id,
        target_user_id: u2.id
      }

      assert {:ok, %UserRelationship{}} =
               Relationship.create_user_relationship(valid_attrs)
    end

    test "delete_user_relationship/1 deletes the user_relationship" do
      u1 = user_fixture()
      u2 = user_fixture()
      rel = user_relationship_fixture(%{source_user_id: u1.id, target_user_id: u2.id})

      assert {:ok, %UserRelationship{}} = Relationship.delete_user_relationship(rel)

      assert_raise Ecto.NoResultsError, fn ->
        Relationship.get_user_relationship!(u1.id, u2.id)
      end
    end

    test "registration_change_user_relationship/3 returns a user_relationship changeset" do
      u1 = user_fixture()
      u2 = user_fixture()
      rel = user_relationship_fixture(%{source_user_id: u1.id, target_user_id: u2.id})

      assert %Ecto.Changeset{} =
               Relationship.registration_change_user_relationship(rel, u1.id, u2.id)
    end
  end
end

プロフィールページにフォロー・フォロー解除ボタンを表示

プロフィールページにアクセスした時、ログインユーザーとプロフィールユーザーとのリレーションシップをチェックして、フォローボタン・フォロー解除ボタンを切り替えます。
フォロー・フォロー解除ボタンを押下するとコンテキスト経由で処理され、結果に応じて表示が切り替わります。

ProfileLive を編集します。
assign の show_follow はフォローボタンの表示状態、is_following はフォロー済み状態を表します。

/lib/app_web/live/profile_live.ex
defmodule AppWeb.ProfileLive do
  use AppWeb, :live_view

  alias App.Accounts
  alias App.Relationship

  @impl true
  def render(assigns) do
    ~H"""
    <.header>
      <h1><%= @target.email %></h1>
      <:actions :if={@show_follow}>
        <.button :if={!@is_following} phx-click="follow" phx-value-id={@target.id}>
          フォローする
        </.button>
        <.button :if={@is_following} phx-click="unfollow" phx-value-id={@target.id}>
          フォロー解除
        </.button>
      </:actions>
    </.header>
    """
  end

  @impl true
  def mount(%{"id" => id}, _session, socket) do
    source = socket.assigns.current_user
    target = Accounts.get_user!(id)
    rel = Relationship.get_user_relationship(source.id, target.id)

    {:ok,
     socket
     |> assign(target: target)
     |> assign(show_follow: source.id != target.id)
     |> assign(is_following: !is_nil(rel))}
  end

  @impl true
  def handle_event("follow", %{"id" => target_id}, socket) do
    attrs = %{
      source_user_id: socket.assigns.current_user.id,
      target_user_id: target_id
    }

    case Relationship.create_user_relationship(attrs) do
      {:ok, _} ->
        {:noreply,
         socket
         |> assign(is_following: true)
         |> put_flash(:info, "フォローしました")}

      {:error, _} ->
        {:noreply, put_flash(socket, :error, "フォローの途中でエラーが発生しました")}
    end
  end

  @impl true
  def handle_event("unfollow", %{"id" => target_id}, socket) do
    rel = Relationship.get_user_relationship!(socket.assigns.current_user.id, target_id)

    case Relationship.delete_user_relationship(rel) do
      {:ok, _} ->
        {:noreply,
         socket
         |> assign(is_following: false)
         |> put_flash(:info, "フォローを解除しました")}

      {:error, _} ->
        {:noreply, put_flash(socket, :error, "フォロー解除の途中でエラーが発生しました")}
    end
  end
end

テストを編集します。
操作後の表示が正しいか検証します。

/test/app_web/live/profile_live_test.exs
defmodule AppWeb.ProfileLiveTest do
  use AppWeb.ConnCase, async: true

  setup :register_and_log_in_user

  import Phoenix.LiveViewTest
  import App.AccountsFixtures
  import App.RelationshipFixtures

  describe "index" do
    test "未フォローの場合、フォローできること", %{conn: conn} do
      target = user_fixture()
      {:ok, lv, _html} = live(conn, ~p"/profiles/#{target}")

      html =
        lv
        |> element("button", "フォローする")
        |> render_click()

      assert html =~ "フォロー解除"
      assert html =~ "フォローしました"
    end

    test "フォロー済みの場合、フォロー解除できること", %{conn: conn, user: user} do
      target = user_fixture()
      _rel = user_relationship_fixture(%{source_user_id: user.id, target_user_id: target.id})
      {:ok, lv, _html} = live(conn, ~p"/profiles/#{target}")

      html =
        lv
        |> element("button", "フォロー解除")
        |> render_click()

      assert html =~ "フォローする"
      assert html =~ "フォローを解除しました"
    end
  end
end

NanaoNanao

フォロー数・フォロワー数の作成

何人をフォローしていて何人からフォローされているかが分かるようにフォロー数・フォロワー数を追加します。
分かりやすいようにフォロー数は following_count、フォロワー数は followers_count と呼んでいます。

User にフィールドを追加

マイグレーションファイルを編集します。

/priv/repo/migrations/xxx_create_users_auth_tables.exs
    create table(:users) do
      add :email, :citext, null: false
      add :hashed_password, :string, null: false
      add :confirmed_at, :naive_datetime

      # 追加
      add :following_count, :integer, default: 0, null: false
      add :followers_count, :integer, default: 0, null: false

      timestamps(type: :utc_datetime)
    end

スキーマファイルを編集します。
更新用に following_count と followers_count の changeset も作成します。

/lib/app/accounts/user.ex
schema "users" do
    field :email, :string
    field :password, :string, virtual: true, redact: true
    field :hashed_password, :string, redact: true
    field :confirmed_at, :naive_datetime

    # 追加
    field :following_count, :integer, default: 0
    field :followers_count, :integer, default: 0

    timestamps(type: :utc_datetime)
  end

...

  def following_count_changeset(user, attrs) do
    user
    |> cast(attrs, [:following_count])
    |> validate_required([:following_count])
    |> validate_number(:following_count, greater_than_or_equal_to: 0)
  end

  def followers_count_changeset(user, attrs) do
    user
    |> cast(attrs, [:followers_count])
    |> validate_required([:followers_count])
    |> validate_number(:followers_count, greater_than_or_equal_to: 0)
  end

フォロー数・フォロワー数の更新処理を追加

フォロー時は自分のフォロー数を+1、相手のフォロワー数を+1します。フォロー解除時はその逆でそれぞれ-1します。
集計関数の COUNT でカウントし、following_count と followers_count カラムに反映します。

Relationship コンテキストに関数を追加します。

/lib/app/relationship.ex
  @doc """
  フォロー数を更新します。
  """
  def update_following_count(id) do
    query = from(r in UserRelationship, where: r.source_user_id == ^id)
    count = Repo.aggregate(query, :count, :id)
    user = Accounts.get_user!(id)

    {:ok, _} =
      user
      |> User.following_count_changeset(%{following_count: count})
      |> Repo.update()
  end

  @doc """
  フォロワー数を更新します。
  """
  def update_followers_count(id) do
    query = from(r in UserRelationship, where: r.target_user_id == ^id)
    count = Repo.aggregate(query, :count, :id)
    user = Accounts.get_user!(id)

    {:ok, _} =
      user
      |> User.followers_count_changeset(%{followers_count: count})
      |> Repo.update()
  end

テストを追加します。

/test/app/relationship_test.exs
    test "update_following_count/1 フォロー数が更新されること" do
      u1 = user_fixture()
      u2 = user_fixture()
      _rel = user_relationship_fixture(%{source_user_id: u1.id, target_user_id: u2.id})

      assert u1.following_count == 0
      assert {:ok, _} = Relationship.update_following_count(u1.id)
      assert App.Accounts.get_user!(u1.id).following_count == 1
    end

    test "update_followers_count/1 フォロワー数が更新されること" do
      u1 = user_fixture()
      u2 = user_fixture()
      _rel = user_relationship_fixture(%{source_user_id: u1.id, target_user_id: u2.id})

      assert u2.followers_count == 0
      assert {:ok, _} = Relationship.update_followers_count(u2.id)
      assert App.Accounts.get_user!(u2.id).followers_count == 1
    end

プロフィールページにフォロー数・フォロワー数を表示

プロフィールページでフォローするとそのプロフィールユーザーのフォロワー数が変化するため followers_count を assign にします。
リレーションシップの作成・削除後にカウント更新を行います。

ProfileLive を編集します。
with を使ってリレーションシップの作成・削除後にカウント更新を行います。

/lib/app_web/live/profile_live.ex
defmodule AppWeb.ProfileLive do
  use AppWeb, :live_view

  alias App.Accounts
  alias App.Accounts.User
  alias App.Relationship

  @impl true
  def render(assigns) do
    ~H"""
    <.header>
      <h1><%= @target.email %></h1>
      <:actions :if={@show_follow}>
        <.button :if={!@is_following} phx-click="follow" phx-value-id={@target.id}>
          フォローする
        </.button>
        <.button :if={@is_following} phx-click="unfollow" phx-value-id={@target.id}>
          フォロー解除
        </.button>
      </:actions>
    </.header>
    <div class="flex gap-4 py-2">
      <div class="p-2"><%= @target.following_count %> フォロー</div>
      <div class="p-2"><%= @followers_count %> フォロワー</div>
    </div>
    """
  end

  @impl true
  def mount(%{"id" => id}, _session, socket) do
    source = socket.assigns.current_user
    target = Accounts.get_user!(id)
    rel = Relationship.get_user_relationship(source.id, target.id)

    {:ok,
     socket
     |> assign(target: target)
     |> assign(show_follow: source.id != target.id)
     |> assign(is_following: !is_nil(rel))
     |> assign(followers_count: target.followers_count)}
  end

  @impl true
  def handle_event("follow", %{"id" => target_id}, socket) do
    attrs = %{
      source_user_id: socket.assigns.current_user.id,
      target_user_id: target_id
    }

    with {:ok, _} <- Relationship.create_user_relationship(attrs),
         {:ok, _} <- Relationship.update_following_count(attrs.source_user_id),
         {:ok, %User{followers_count: followers_count}} <-
           Relationship.update_followers_count(attrs.target_user_id) do
      {:noreply,
       socket
       |> assign(is_following: true)
       |> assign(followers_count: followers_count)
       |> put_flash(:info, "フォローしました")}
    else
      _ -> {:noreply, put_flash(socket, :error, "フォローの途中でエラーが発生しました")}
    end
  end

  @impl true
  def handle_event("unfollow", %{"id" => target_id}, socket) do
    rel = Relationship.get_user_relationship!(socket.assigns.current_user.id, target_id)

    with {:ok, _} <- Relationship.delete_user_relationship(rel),
         {:ok, _} <- Relationship.update_following_count(rel.source_user_id),
         {:ok, %User{followers_count: followers_count}} <-
           Relationship.update_followers_count(rel.target_user_id) do
      {:noreply,
       socket
       |> assign(is_following: false)
       |> assign(followers_count: followers_count)
       |> put_flash(:info, "フォローを解除しました")}
    else
      _ -> {:noreply, put_flash(socket, :error, "フォロー解除の途中でエラーが発生しました")}
    end
  end
end

テストを編集します。
操作後の表示が正しいことを検証します。

/test/app_web/live/profile_live_test.exs
defmodule AppWeb.ProfileLiveTest do
  use AppWeb.ConnCase, async: true

  setup :register_and_log_in_user

  import Phoenix.LiveViewTest
  import App.AccountsFixtures
  import App.RelationshipFixtures

  describe "index" do
    test "フォローができること", %{conn: conn, user: user} do
      target = user_fixture()

      # 相手のプロフィールページへ移動し、フォローボタンをクリックする
      {:ok, lv, html} = live(conn, ~p"/profiles/#{target}")
      assert html =~ "0 フォロワー"

      html =
        lv
        |> element("button", "フォローする")
        |> render_click()

      assert html =~ "フォロー解除"
      assert html =~ "フォローしました"
      assert html =~ "1 フォロワー"

      # 自分のプロフィールページへ移動する
      {:ok, _lv, html} = live(conn, ~p"/profiles/#{user}")
      assert html =~ "1 フォロー"
    end

    test "フォロー解除ができること", %{conn: conn, user: user} do
      target = user_fixture()
      _rel = user_relationship_fixture(%{source_user_id: user.id, target_user_id: target.id})
      assert {:ok, _} = App.Relationship.update_following_count(user.id)
      assert {:ok, _} = App.Relationship.update_followers_count(target.id)

      # 相手のプロフィールページへ移動し、フォロー解除ボタンをクリックする
      {:ok, lv, html} = live(conn, ~p"/profiles/#{target}")
      assert html =~ "1 フォロワー"

      html =
        lv
        |> element("button", "フォロー解除")
        |> render_click()

      assert html =~ "フォローする"
      assert html =~ "フォローを解除しました"
      assert html =~ "0 フォロワー"

      # 自分のプロフィールページへ移動する
      {:ok, _lv, html} = live(conn, ~p"/profiles/#{user}")
      assert html =~ "0 フォロー"
    end
  end
end

NanaoNanao

フォローリスト・フォロワーリストの作成

誰をフォローしていて誰からフォローされているかが分かるようにユーザーをリスト表示します。
フォローリスト・フォロワーリストの取得はリレーションシップを通して行います。

リストの取得処理を作成

Relationship コンテキストに関数を追加します。
ページング用にオプションで limit と offset を受け取ってその位置のリストを返せるようにしています。

/lib/app/relationship.ex
  # 1ページあたりの表示件数
  @count_per_page 10

  @doc """
  フォローリストを取得します。

  ## Options

  - limit
  - offset
  """
  def list_following(id, opts \\ []) do
    limit = Keyword.get(opts, :limit, @count_per_page)
    offset = Keyword.get(opts, :offset, 0)

    query =
      from(
        r in UserRelationship,
        where: r.source_user_id == ^id,
        join: u in assoc(r, :target_user),
        select: u,
        order_by: [desc: r.inserted_at],
        limit: ^limit,
        offset: ^offset
      )

    Repo.all(query)
  end

  @doc """
  フォロワーリストを取得します。

  ## Options

  - limit
  - offset
  """
  def list_followers(id, opts \\ []) do
    limit = Keyword.get(opts, :limit, @count_per_page)
    offset = Keyword.get(opts, :offset, 0)

    query =
      from(
        r in UserRelationship,
        where: r.target_user_id == ^id,
        join: u in assoc(r, :source_user),
        select: u,
        order_by: [desc: r.inserted_at],
        limit: ^limit,
        offset: ^offset
      )

    Repo.all(query)
  end

テストを追加します。

/test/app/relationship_test.exs
    test "list_following/1 フォローリストが取得できること" do
      u1 = user_fixture()
      u2 = user_fixture()
      _rel = user_relationship_fixture(%{source_user_id: u1.id, target_user_id: u2.id})

      assert Relationship.list_following(u1.id) == [u2]
    end

    test "list_followers/1 フォロワーリストが取得できること" do
      u1 = user_fixture()
      u2 = user_fixture()
      _rel = user_relationship_fixture(%{source_user_id: u1.id, target_user_id: u2.id})

      assert Relationship.list_followers(u2.id) == [u1]
    end

リレーションシップページにリストを表示

フォローリスト・フォロワーリストは共通のページで表示し、アクションで切り替えます。
現在のページをパラメータから受け取って limit と offset に変換することでページングもできますがここでは省略しています。

RelationshipLive を作成します。
アクションはsocket.assigns.live_actionで取得できます。
aria-currentはアクセシビリティ属性です。見た目には影響しませんが現在選択しているページをスクリーンリーダーに伝えてくれます。

/lib/app_web/live/relationship_live.ex
defmodule AppWeb.RelationshipLive do
  use AppWeb, :live_view

  alias App.Accounts
  alias App.Relationship

  @count_per_page 10

  @impl true
  def render(assigns) do
    ~H"""
    <.header>
      <span><%= @target.email %>さんの<%= @page_title %></span>
      <:actions>
        <.link
          patch={~p"/profiles/#{@target.id}/following"}
          aria-current={@live_action == :following and "page"}
        >
          <.button>フォロー中</.button>
        </.link>
        <.link
          patch={~p"/profiles/#{@target.id}/followers"}
          aria-current={@live_action == :followers and "page"}
        >
          <.button>フォロワー</.button>
        </.link>
      </:actions>
    </.header>
    <div id="users" phx-update="stream">
      <article :for={{dom_id, user} <- @streams.users} id={dom_id}>
        <.link navigate={~p"/profiles/#{user}"}>
          <h3><%= user.email %></h3>
        </.link>
      </article>
    </div>
    """
  end

  @impl true
  def mount(%{"id" => id}, _session, socket) do
    {:ok,
     socket
     |> assign(target: Accounts.get_user!(id))
     |> stream(:users, [], limit: @count_per_page)}
  end

  @impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  defp apply_action(socket, :following, %{"id" => id}) do
    socket
    |> assign(page_title: "フォロー中リスト")
    |> stream(:users, Relationship.list_following(id), limit: @count_per_page, reset: true)
  end

  defp apply_action(socket, :followers, %{"id" => id}) do
    socket
    |> assign(page_title: "フォロワーリスト")
    |> stream(:users, Relationship.list_followers(id), limit: @count_per_page, reset: true)
  end
end

ルーティングを編集してフォローリストとフォロワーリストのルートを追加します。

/lib/app_web/router.ex
scope "/", AppWeb do
    pipe_through [:browser, :require_authenticated_user]

    live_session :require_authenticated_user,
      on_mount: [{AppWeb.UserAuth, :ensure_authenticated}] do
      live "/profiles/:id", ProfileLive

      # 追加
      live "/profiles/:id/following", RelationshipLive, :following
      live "/profiles/:id/followers", RelationshipLive, :followers

      live "/users/settings", UserSettingsLive, :edit
      live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
    end

テストを追加します。

/test/app_web/live/relationship_live_test.exs
defmodule AppWeb.RelationshipLiveTest do
  use AppWeb.ConnCase, async: true

  setup :register_and_log_in_user

  import Phoenix.LiveViewTest
  import App.AccountsFixtures
  import App.RelationshipFixtures

  describe "index" do
    test "フォローリストが表示できること", %{conn: conn, user: user} do
      target = user_fixture()
      _rel = user_relationship_fixture(%{source_user_id: user.id, target_user_id: target.id})

      {:ok, _lv, html} = live(conn, ~p"/profiles/#{user}/following")
      assert html =~ "#{user.email}さんのフォロー中リスト"
      assert html =~ target.email
    end

    test "フォロワーリストが表示できること", %{conn: conn, user: user} do
      target = user_fixture()
      _rel = user_relationship_fixture(%{source_user_id: user.id, target_user_id: target.id})

      {:ok, _lv, html} = live(conn, ~p"/profiles/#{target}/followers")
      assert html =~ "#{target.email}さんのフォロワーリスト"
      assert html =~ user.email
    end
  end
end

最後にプロフィールページをリレーションシップページとリンクします。
リレーションシップページは/profiles/123/followingまたは/profiles/123/followersのようなパスでアクセスできます。

/lib/app_web/live/profile_live.ex
  @impl true
  def render(assigns) do
    ~H"""
    <.header>
      <h1><%= @target.email %></h1>
      <:actions :if={@show_follow}>
        <.button :if={!@is_following} phx-click="follow" phx-value-id={@target.id}>
          フォローする
        </.button>
        <.button :if={@is_following} phx-click="unfollow" phx-value-id={@target.id}>
          フォロー解除
        </.button>
      </:actions>
    </.header>
    <div class="flex gap-4 py-2">
      <div class="p-2">
        <.link navigate={~p"/profiles/#{@target}/following"}>
          <%= @target.following_count %> フォロー
        </.link>
      </div>
      <div class="p-2">
        <.link navigate={~p"/profiles/#{@target}/followers"}>
          <%= @followers_count %> フォロワー
        </.link>
      </div>
    </div>
    """
  end
このスクラップは3ヶ月前にクローズされました