In the last episode, we added our first polymorphic schema to support watching threads. In the future, we'll support watching users as well. However, there's no UI support for watching a thread. Let's support watches in the UI. While we're at it, we'll modify the Notifications to only notify users that are watching a thread when a new post is created. Let's get started.

Project

We're starting with the dailydrip/firestorm repo tagged before this episode.

Adding a controller action to handle Watches

Let's add a controller action to handle watching a thread. We'll start with a feature test:

vim test/feature/threads_test.exs
defmodule FirestormWeb.Feature.ThreadsTest do
  # ...
  test "watching a thread", %{session: session} do
    import Page.Thread.Show

    {:ok, [elixir]} = create_categories(["Elixir"])
    {:ok, user} = Forums.create_user(%{username: "knewter", email: "josh@dailydrip.com", name: "Josh Adams"})
    {:ok, otp_is_cool} = Forums.create_thread(elixir, user, @otp_is_cool_parameters)

    session
    |> log_in_as(user)
    |> visit(category_thread_path(FirestormWeb.Web.Endpoint, :show, elixir, otp_is_cool))
    |> refute_has(watched_icon())
    |> click(watch_link())
    |> assert_has(watched_icon())
  end
  # ...
end

Here we'd like to assert that the watched_icon() doesn't exist - this would be the eyeball icon with an active state. Then we'll click the watch_link(), which is that same icon regardless of the state. Finally, we'll assert that the watched_icon() exists. Let's add these selectors to the Page.Thread.Show module.

vim test/support/page/thread/show.ex
defmodule Page.Thread.Show do
  # ...
  def watched_icon(), do: css(".fa-eye.-highlight")

  def watch_link(), do: action_link("watch")

  defp action_link(name) do
    css("#{first_post_actions_selector()} > li.#{name} > a")
  end

  defp first_post_actions_selector() do
    ".first-post > .post-item-actions > .actions"
  end
end

If we run the test now, we see that everything works until it looks for the icon to have been marked -highlighted. Let's next implement a controller action.

vim lib/firestorm_web/web/router.ex
defmodule FirestormWeb.Web.Router do
  # ...
  scope "/", FirestormWeb.Web do
    # ...
    resources "/categories", CategoryController do
      get "/threads/:id/watch", ThreadController, :watch
      get "/threads/:id/unwatch", ThreadController, :unwatch

      resources "/threads", ThreadController do
        # ...
      end
    end
  end
  # ...
end

Here we've added support for "unwatching" as well, which we'll implement as well. Next, let's add the controller actions:

vim lib/firestorm_web/web/controllers/thread_controller.ex
defmodule FirestormWeb.Web.ThreadController do
  # ...
  plug FirestormWeb.Web.Plugs.RequireUser when action in [:new, :create, :watch, :unwatch]
  # ...
  def watch(conn, %{"id" => id}, category) do
    thread =
      Forums.get_thread!(category, id)

    current_user(conn)
    |> Forums.watch(thread)

    conn
    |> redirect to: category_thread_path(conn, :show, category.id, id)
  end

  def unwatch(conn, %{"id" => id}, category) do
    # FIXME: Implement this
    conn
    |> redirect to: category_thread_path(conn, :show, category.id, id)
  end
  # ...
end

This doesn't make the test pass yet. For that, we need to identify whether or not the thread is watched. We'll add that as an assign on the show action:

defmodule FirestormWeb.Web.ThreadController do
  # ...
  def show(conn, %{"id" => id}, category) do
    thread =
      Forums.get_thread!(category, id)
      |> Repo.preload(:posts)

    [ first_post | posts ] = thread.posts

    watched =
      if current_user(conn) do
        thread |> Forums.watched_by?(current_user(conn))
      else
        false
      end

    render(conn, "show.html", thread: thread, category: category, first_post: first_post, posts: posts, watched: watched)
  end
  # ...
end

Now we'll need to pass this assign through to the _first_post.html.eex template. We start in the thread's show.html.eex template:

<!-- ... -->
<ol class="post-list">
  <li><%= render "_first_post.html", post: @first_post, conn: @conn, category: @category, thread: @thread, watched: @watched %></li>
  <!-- ... -->
</ol>
<!-- ... -->

Then we'll move on to _first_post.html.eex:

<div class="post-item first-post">
  <!-- ... -->
  <div class="post-item-actions">
    <!-- ... -->
    <ul class="actions">
      <!-- ... -->
      <li class="watch">
        <a href="<%= category_thread_path(@conn, :watch, @category, @thread) %>">
          <i class="fa fa-eye <%= if @watched, do: "-highlight", else: "" %>"></i>
        </a>
      </li>
      <!-- ... -->
    </ul>
  </div>
</div>

With that, the tests pass. Let's extend the test to ensure we can unwatch by clicking the link again:

  test "watching a thread", %{session: session} do
    # ...
    session
    |> log_in_as(user)
    |> visit(category_thread_path(FirestormWeb.Web.Endpoint, :show, elixir, otp_is_cool))
    |> refute_has(watched_icon())
    |> click(watch_link())
    |> assert_has(watched_icon())
    |> click(watch_link()) # <-- unwatch it now
    |> refute_has(watched_icon()) # <-- ensure it unwatches
  end

If we run this test, it fails again because we haven't implemented unwatching. Let's do that quickly:

  def unwatch(conn, %{"id" => id}, category) do
    thread =
      Forums.get_thread!(category, id)

    current_user(conn)
    |> Forums.unwatch(thread)

    conn
    |> redirect to: category_thread_path(conn, :show, category.id, id)
  end

We'll implement Forums.unwatch by deleting all watches for this user and thread:

  @doc """
  Ensure a user no longer watches a thread:

      iex> %User{} |> unwatch(%Thread{})
      :ok

  """
  def unwatch(%User{} = user, %Thread{} = thread) do
    # Here we'll use a table name as a string, rather than a schema, as our
    # primary source query. You can do this if you want to interact with a
    # database without going through Schemas.
    "forums_threads_watches"
    |> where(assoc_id: ^thread.id)
    |> where(user_id: ^user.id)
    |> Repo.delete_all()

    :ok
  end

Now we need to update the link's href depending on whether or not the thread is being watched:

<!-- ... -->
      <li class="watch">
        <%= if @watched do %>
          <a href="<%= category_thread_path(@conn, :unwatch, @category, @thread) %>">
            <i class="fa fa-eye -highlight"></i>
          </a>
        <% else %>
          <a href="<%= category_thread_path(@conn, :watch, @category, @thread) %>">
            <i class="fa fa-eye"></i>
          </a>
        <% end %>
      </li>
<!-- ... -->

With that, the tests pass. The only remaining feature for today is to only notify users by email if they're watching a thread. First, we'll update the notifications test:

defmodule FirestormWeb.NotificationsTest do
  use FirestormWeb.DataCase
  alias FirestormWeb.Forums
  use Bamboo.Test, shared: true

  test "creating a post in a thread notifies everyone watching the thread" do
    {:ok, user} = Forums.create_user(%{username: "knewter", email: "josh@dailydrip.com", name: "Josh Adams"})
    # Create a new user
    {:ok, bob} = Forums.create_user(%{username: "bob", email: "bob@bob.com", name: "Bob Vladbob"})
    {:ok, elixir} = Forums.create_category(%{title: "Elixir"})
    # Have bob create the thread - he would have received a notification since
    # he posted in the thread under the old logic.
    {:ok, otp_is_cool} = Forums.create_thread(elixir, bob, %{title: "OTP is cool", body: "Don't you think?"})
    # Watch the thread from a user that hasn't posted in it.
    {:ok, _} = user |> Forums.watch(otp_is_cool)
    # Post in the thread as bob again
    {:ok, yup} = Forums.create_post(otp_is_cool, bob, %{body: "yup"})
    # The watching user receives an email
    assert_delivered_email FirestormWeb.Emails.thread_new_post_notification(user, otp_is_cool, yup)
    # The involved user doesn't since he's not watching
    refute_delivered_email FirestormWeb.Emails.thread_new_post_notification(bob, otp_is_cool, yup)
  end
end

If we try to run the test now, it will fail because we need to configure Bamboo a bit more if we want to refute delivery:

vim config/test.exs
# ...
config :bamboo, :refute_timeout, 10

Now if we run it, it fails. Let's fix the logic:

defmodule FirestormWeb.Notifications do
  # ...
  def handle_cast({:post_created, %Post{} = post}, :nostate) do
    # 1) Find all users that are watching this thread
    with post when not is_nil(post) <- Repo.preload(post, [thread: [:watchers]]),
         thread when not is_nil(thread) <- post.thread,
         users <- thread.watchers |> Enum.uniq do
           # 2) Send each of them an email about it
           for user <- users do
             Emails.thread_new_post_notification(user, post.thread, post)
             |> Mailer.deliver_now
           end
    else
      _ ->
        :ok
    end
    {:noreply, :nostate}
  end
end

With this, the test passes, and our users are less likely to get angry with us.

Summary

In today's episode, we added UI support for watching a thread and unwatching it, we extended the Forums context to support unwatch, and we modified our Notifications to be a bit smarter. See you soon!

Resources