lib/openagents_web/live/forum_topic_live.ex

main at 436d37facc96 · 12 KB

defmodule OpenAgentsWeb.ForumTopicLive do
  @moduledoc """
  One topic: its posts, oldest first, with a reply composer.

  The page follows the topic rather than showing it as it stood when you
  opened it. A reply written anywhere -- in another browser, through the API,
  or by an agent -- arrives without a reload, and so does a post an operator
  hides and a topic they close.

  The announcement carries a topic id and nothing else (#154), so the page
  re-reads through `Forum.fetch_readable_topic/2` with the same scope it
  mounted under. A board that stops being readable takes its topic with it,
  and a post keeps the identity it was written under: a migrated post's byline
  is its legacy name until a claim binds it, on a refresh no less than on a
  mount.
  """
  use OpenAgentsWeb, :live_view

  alias OpenAgents.Forum
  # Tipping is commented out until the payment service is enabled here.
  # alias OpenAgents.Forum.Tips
  alias OpenAgents.Markdown
  alias OpenAgentsWeb.LiveRefresh
  alias OpenAgentsWeb.OG

  # Preset amounts keep tipping one click. Larger amounts go through the API.
  # @tip_amounts [100, 1_000]

  def mount(%{"id" => id}, _session, socket) do
    scope = [operator?: OpenAgents.Accounts.admin?(socket.assigns[:current_user])]

    case Forum.fetch_readable_topic(id, scope) do
      {:error, :not_found} ->
        {:ok,
         socket
         |> put_flash(:error, "Topic not found")
         |> push_navigate(to: ~p"/forum")}

      {:ok, topic} ->
        if connected?(socket), do: Forum.subscribe_posts()

        posts = Forum.list_posts(topic)
        board = Forum.get_forum!(topic.forum_id)

        {:ok,
         socket
         |> LiveRefresh.init()
         |> assign(:current_scope, socket.assigns[:current_scope])
         |> assign(:read_scope, scope)
         |> assign(:topic, topic)
         |> assign(:board, board)
         |> assign(:page_title, topic.title)
         |> assign(:og, topic_og(board, topic, posts))
         |> assign(:posts, posts)
         |> stream(:posts, posts)
         |> assign(:form, to_form(%{"body_text" => ""}, as: :post))}
    end
  end

  # Every post on the forum announces itself on one topic, so a page reading
  # one topic can ignore the rest by id alone rather than by reading to find
  # out. A busy board costs this page nothing.
  def handle_info({:forum_posts_changed, topic_id}, socket) do
    if topic_id == socket.assigns.topic.id,
      do: {:noreply, LiveRefresh.mark_stale(socket, :posts, &refresh_panel/2)},
      else: {:noreply, socket}
  end

  def handle_info(:live_refresh, socket),
    do: {:noreply, LiveRefresh.run(socket, &refresh_panel/2)}

  def handle_event("reply", %{"post" => %{"body_text" => body_text}}, socket)
      when body_text == "" or is_nil(body_text) do
    {:noreply, put_flash(socket, :error, "Write something first")}
  end

  def handle_event("reply", %{"post" => params}, socket) do
    user = current_user(socket)

    case user do
      nil ->
        {:noreply, put_flash(socket, :error, "Sign in to post")}

      user ->
        attrs = %{
          body_text: params["body_text"],
          actor_ref: "user:#{user.id}",
          actor_display_name: user.github_name || user.github_login,
          actor_slug: user.github_login,
          actor_is_agent: false,
          idempotency_key: Ecto.UUID.generate()
        }

        case Forum.create_post(socket.assigns.topic, attrs) do
          {:ok, _post} ->
            {:noreply,
             socket
             |> refresh_panel(:posts)
             |> assign(:form, to_form(%{"body_text" => ""}, as: :post))}

          {:error, :topic_closed} ->
            {:noreply, put_flash(socket, :error, "This topic is closed")}
        end
    end
  end

  # Tipping pays the author's own destination. A fresh idempotency key per
  # click means a double click cannot pay twice for the same request.
  #
  # Commented out until the payment service is enabled here: with it disabled,
  # every click answered "Tipping is not enabled here yet".
  #
  # def handle_event("tip", %{"id" => id, "amount" => amount}, socket) do
  #   with %{} = user <- current_user(socket),
  #        {sats, ""} <- Integer.parse(amount),
  #        post when not is_nil(post) <- Enum.find(socket.assigns.posts, &(&1.id == id)) do
  #     request = %{
  #       post: post,
  #       payer_user: user,
  #       payer_actor_ref: "user:#{user.id}",
  #       amount_sats: sats,
  #       idempotency_key: Ecto.UUID.generate()
  #     }
  #
  #     case Tips.tip_post(request) do
  #       {:ok, intent} ->
  #         {:noreply, socket |> refresh_panel(:posts) |> put_flash(:info, tip_message(intent))}
  #
  #       {:error, {:payment_failed, intent}} ->
  #         {:noreply,
  #          put_flash(socket, :error, "Payment failed: #{intent.failure_code}. Nothing was sent.")}
  #
  #       {:error, reason} ->
  #         {:noreply, put_flash(socket, :error, tip_error(reason))}
  #     end
  #   else
  #     _unavailable -> {:noreply, put_flash(socket, :error, "Sign in to tip")}
  #   end
  # end

  def handle_event("toggle_closed", _params, socket) do
    with %{} = user <- current_user(socket),
         true <- OpenAgents.Accounts.admin?(user) do
      new_state = if socket.assigns.topic.state == "open", do: "closed", else: "open"
      {:ok, _} = Forum.set_topic_state(socket.assigns.topic, new_state)

      {:noreply, refresh_panel(socket, :posts)}
    else
      _ -> {:noreply, put_flash(socket, :error, "Operators only")}
    end
  end

  def handle_event("hide_post", %{"id" => id}, socket) do
    with %{} = user <- current_user(socket),
         true <- OpenAgents.Accounts.admin?(user),
         post <- Enum.find(socket.assigns.posts, &(&1.id == id)),
         {:ok, _} <- Forum.hide_post(post, user) do
      {:noreply, refresh_panel(socket, :posts)}
    else
      _ -> {:noreply, put_flash(socket, :error, "Operators only")}
    end
  end

  def render(assigns) do
    ~H"""
    <Layouts.app
      flash={@flash}
      sidebar_sections={assigns[:sidebar_sections]}
      current_scope={@current_scope}
    >
      <div class="mx-auto w-full max-w-3xl">
        <nav class="flex items-center gap-1.5 mb-2 text-sm text-muted-foreground">
          <.link navigate={~p"/forum"} class="hover:text-foreground">Forum</.link>
          <span aria-hidden="true">/</span>
          <.link navigate={~p"/forum/f/#{@board.slug}"} class="hover:text-foreground">
            {@board.title}
          </.link>
        </nav>

        <header class="mb-6">
          <h1 class="text-2xl font-semibold tracking-tight text-balance">{@topic.title}</h1>
          <div class="flex flex-wrap items-center gap-x-3 gap-y-1 mt-2 text-sm text-muted-foreground">
            <span>{@topic.actor_display_name}</span>
            <span aria-hidden="true">·</span>
            <time datetime={DateTime.to_iso8601(@topic.created_at)}>
              {Calendar.strftime(@topic.created_at, "%b %d, %Y")}
            </time>
            <span aria-hidden="true">·</span>
            <span>{@topic.post_count} posts</span>
            <%= if @topic.state == "closed" do %>
              <span class="badge" data-variant="dim">closed</span>
            <% end %>
            <%= if OpenAgents.Accounts.admin?(@current_user) do %>
              <button
                class="btn ml-auto"
                data-variant="ghost"
                data-size="sm"
                phx-click="toggle_closed"
              >
                {if @topic.state == "open", do: "Close topic", else: "Reopen topic"}
              </button>
            <% end %>
          </div>
        </header>

        <%!-- Tipping is commented out until the payment service is enabled here. --%>

        <div id="posts" phx-update="stream" class="flex flex-col gap-4">
          <div id="posts-empty" class="hidden only:block text-sm text-muted-foreground">
            No posts yet.
          </div>
          <article :for={{id, post} <- @streams.posts} id={id} class="card !m-0">
            <header class="flex flex-wrap items-baseline gap-x-2 gap-y-1 mb-3">
              <span class="text-sm font-semibold">{post.actor_display_name}</span>
              <%= if post.actor_is_agent do %>
                <span class="badge" data-variant="dim">agent</span>
              <% end %>
              <time
                class="text-xs text-muted-foreground"
                datetime={DateTime.to_iso8601(post.created_at)}
              >
                {Calendar.strftime(post.created_at, "%b %d, %Y · %H:%M UTC")}
              </time>
              <span class="ml-auto flex items-center gap-2">
                <%= if OpenAgents.Accounts.admin?(@current_user) do %>
                  <button
                    class="btn"
                    data-variant="ghost"
                    data-size="sm"
                    phx-click="hide_post"
                    phx-value-id={post.id}
                  >
                    Hide
                  </button>
                <% end %>
                <%= if post.tip_count > 0 do %>
                  <span class="badge" data-variant="dim" title="Settled tips">
                    {post.tip_sats_total} sats
                  </span>
                <% end %>
                <span class="text-xs text-muted-foreground">#{post.post_number}</span>
              </span>
            </header>
            <div class="markdown">
              {Markdown.to_html(post.body_text)}
            </div>
            <%!-- Tipping is commented out until the payment service is enabled here.
            <footer :if={@current_user} class="flex items-center gap-2 mt-3">
              <span class="text-xs text-muted-foreground">Tip the author</span>
              <button
                :for={amount <- tip_amounts()}
                class="btn"
                data-variant="ghost"
                data-size="sm"
                phx-click="tip"
                phx-value-id={post.id}
                phx-value-amount={amount}
              >
                {amount} sats
              </button>
            </footer>
            --%>
          </article>
        </div>

        <%= if @topic.state == "open" do %>
          <.form for={@form} id="reply-form" phx-submit="reply" class="card !mx-0 !mt-8">
            <.input field={@form[:body_text]} label="Reply" type="textarea" rows={4} required />
            <footer class="flex justify-end mt-2">
              <.button type="submit" variant={:primary}>Post reply</.button>
            </footer>
          </.form>
        <% else %>
          <p class="mt-8 text-sm text-muted-foreground">
            This topic is closed. New replies are not accepted.
          </p>
        <% end %>
      </div>
    </Layouts.app>
    """
  end

  defp current_user(socket), do: socket.assigns[:current_user]

  defp topic_og(board, topic, posts) do
    summary = with %{body_text: body} <- List.first(posts), do: body

    OG.meta(OG.forum_topic(board, topic, summary: summary))
  end

  # The one re-read this page has, whether the write was made here or heard
  # about. It goes back through the same authorized read that filled the page
  # at mount, so a topic whose board has stopped being readable leaves rather
  # than staying open on a scope it no longer belongs to.
  defp refresh_panel(socket, :posts) do
    case Forum.fetch_readable_topic(socket.assigns.topic.id, socket.assigns.read_scope) do
      {:ok, topic} ->
        posts = Forum.list_posts(topic)

        socket
        |> assign(:topic, topic)
        |> assign(:posts, posts)
        |> stream(:posts, posts, reset: true)

      {:error, :not_found} ->
        socket
        |> put_flash(:error, "Topic not found")
        |> push_navigate(to: ~p"/forum")
    end
  end

  # Tipping helpers, commented out with the tip event above.
  #
  # defp tip_message(%{counted_sats: 0, exclusion_reason: reason, amount_sats: sats})
  #      when is_binary(reason) do
  #   "Sent #{sats} sats. This tip does not change ranking (#{String.replace(reason, "_", " ")})."
  # end
  #
  # defp tip_message(%{amount_sats: sats}), do: "Sent #{sats} sats"
  #
  # defp tip_error(:tipping_disabled), do: "Tipping is not enabled here yet"
  # defp tip_error(:no_destination), do: "This author has no tip destination yet"
  # defp tip_error(:not_accepting_tips), do: "This author is not accepting tips"
  # defp tip_error(:post_not_visible), do: "This post cannot be tipped"
  #
  # defp tip_error(:payment_service_unavailable),
  #   do: "The payment service is unavailable. Nothing was sent."
  #
  # defp tip_error(_reason), do: "That tip could not be sent"
  #
  # defp tip_amounts, do: @tip_amounts
end