From a55bd9612dc087f78bf763dc94a008fcda871e4f Mon Sep 17 00:00:00 2001
From: Steffen Beyer
Date: Mon, 25 May 2026 12:50:05 +0200
Subject: [PATCH] refactor: extract reusable chat panel component
Move Aether chat panel behavior into a LiveComponent so other plugins can embed it without an iframe while ChatLive continues to own standalone routing.
---
lib/aether_web/live/chat_live.ex | 271 ++----------------
lib/aether_web/live/chat_panel_component.ex | 287 ++++++++++++++++++++
2 files changed, 313 insertions(+), 245 deletions(-)
create mode 100644 lib/aether_web/live/chat_panel_component.ex
diff --git a/lib/aether_web/live/chat_live.ex b/lib/aether_web/live/chat_live.ex
index d4e3fb5..6093dd5 100644
--- a/lib/aether_web/live/chat_live.ex
+++ b/lib/aether_web/live/chat_live.ex
@@ -4,89 +4,39 @@ defmodule AetherWeb.ChatLive do
on_mount({Tribes.Plugin.LiveUserAuth, :live_user_optional})
alias Aether.Chat
- alias Aether.Chat.{Channel, Message}
+ alias AetherWeb.ChatPanelComponent
alias Tribes.Plugin.Layouts
+ @component_id "aether-chat-panel-component"
+
@impl true
def mount(_params, session, socket) do
- %{mode: mode, slug: slug, backend: backend} = channel_request(session)
+ request = channel_request(session)
- case Chat.ensure_channel(%{slug: slug, title: channel_title(slug), backend: backend}) do
- {:ok, %Channel{} = channel} ->
- messages = load_messages(channel)
- message_ids = messages |> Enum.map(& &1.id) |> MapSet.new()
-
- socket =
- socket
- |> assign(:page_title, channel.title)
- |> assign(:channel, channel)
- |> assign(:embedded?, mode == :embedded)
- |> assign(:backend, channel.backend)
- |> assign(:message_ids, message_ids)
- |> assign(:message_count, MapSet.size(message_ids))
- |> stream_configure(:messages, dom_id: &("chat-message-" <> &1.id))
- |> stream(:messages, messages)
-
- if connected?(socket) do
- _ = Chat.subscribe_channel(channel)
- end
-
- {:ok, socket}
-
- {:error, reason} ->
- {:ok,
- socket
- |> assign(:page_title, "Chat")
- |> assign(:channel, nil)
- |> assign(:embedded?, mode == :embedded)
- |> assign(:backend, backend)
- |> assign(:message_ids, MapSet.new())
- |> assign(:message_count, 0)
- |> assign(:chat_error, inspect(reason))
- |> stream_configure(:messages, dom_id: &("chat-message-" <> &1.id))
- |> stream(:messages, [])}
- end
+ {:ok,
+ socket
+ |> assign(:page_title, channel_title(request.slug))
+ |> assign(:chat_request, request)
+ |> assign(:component_id, @component_id)}
end
@impl true
- def handle_event("send_message", %{"message" => %{"body" => body}}, socket) do
- body = String.trim(body)
-
- cond do
- socket.assigns.channel == nil ->
- {:noreply, put_flash(socket, :error, "Chat channel is unavailable")}
-
- socket.assigns.current_user == nil ->
- {:noreply, put_flash(socket, :error, "Sign in to chat")}
-
- body == "" ->
- {:noreply, put_flash(socket, :error, "Message cannot be empty")}
-
- true ->
- post_message(socket, body)
- end
+ def handle_info({:aether_chat, :message, message}, socket) do
+ send_update(ChatPanelComponent, id: @component_id, incoming_message: message)
+ {:noreply, socket}
end
@impl true
- def handle_info({:aether_chat, :message, %Message{} = message}, socket) do
- if socket.assigns.channel && message.channel_id == socket.assigns.channel.id do
- {:noreply, put_message(socket, message)}
- else
- {:noreply, socket}
- end
- end
-
- @impl true
- def render(%{embedded?: true} = assigns) do
+ def render(%{chat_request: %{mode: :embedded}} = assigns) do
~H"""
- <.chat_panel
- channel={@channel}
+ <.live_component
+ module={ChatPanelComponent}
+ id={@component_id}
+ slug={@chat_request.slug}
+ backend={@chat_request.backend}
+ embedded?={true}
current_user={@current_user}
- embedded?={@embedded?}
- backend={@backend}
- message_count={@message_count}
- streams={@streams}
/>
"""
@@ -103,169 +53,28 @@ defmodule AetherWeb.ChatLive do
Aether chat
- {if @channel, do: @channel.title, else: "Chat"}
+ {channel_title(@chat_request.slug)}
Public synced group chat. Marmot-backed channels will come later without changing the chat surface.
-
- {@message_count} messages
-
- <.chat_panel
- channel={@channel}
+ <.live_component
+ module={ChatPanelComponent}
+ id={@component_id}
+ slug={@chat_request.slug}
+ backend={@chat_request.backend}
+ embedded?={false}
current_user={@current_user}
- embedded?={@embedded?}
- backend={@backend}
- message_count={@message_count}
- streams={@streams}
/>
"""
end
- defp chat_panel(assigns) do
- ~H"""
-
-
-
-
- Marmot backend scaffold is enabled for this channel. Browser transport, storage, and signing are not active yet.
-
-
-
-
- No messages yet. Start the conversation.
-
-
-
-
- {author_initial(message)}
-
-
-
- {author_label(message)}
-
-
-
-
{message.body}
-
-
-
-
- <%= cond do %>
- <% @backend == :marmot -> %>
-
- Marmot chat is not enabled in the web UI yet.
-
- <% @current_user -> %>
-
- <% true -> %>
-
- Sign in to join the chat.
-
- <% end %>
-
- """
- end
-
- defp post_message(socket, body) do
- user = socket.assigns.current_user
-
- attrs = %{
- body: body,
- author_id: user_attr(user, :id),
- author_pubkey: user_attr(user, :pubkey_hex) || user_attr(user, :pubkey),
- metadata: %{"author_name" => user_attr(user, :username)}
- }
-
- case Chat.post_message(socket.assigns.channel, attrs) do
- {:ok, message} ->
- {:noreply, put_message(socket, message)}
-
- {:error, reason} ->
- {:noreply, put_flash(socket, :error, "Failed to send message: #{inspect(reason)}")}
- end
- end
-
- defp put_message(socket, %Message{} = message) do
- if MapSet.member?(socket.assigns.message_ids, message.id) do
- socket
- else
- message_ids = MapSet.put(socket.assigns.message_ids, message.id)
-
- socket
- |> assign(:message_ids, message_ids)
- |> assign(:message_count, MapSet.size(message_ids))
- |> stream_insert(:messages, message, at: -1)
- end
- end
-
- defp load_messages(%Channel{} = channel) do
- case Chat.list_messages(channel) do
- {:ok, messages} -> messages
- {:error, _reason} -> []
- end
- end
-
defp channel_request(%{"plugin_path" => path}) when is_binary(path) do
case String.split(path, "/", trim: true) do
["aether", "chat", "embed", slug | _rest] ->
@@ -304,32 +113,4 @@ defmodule AetherWeb.ChatLive do
|> String.split(" ", trim: true)
|> Enum.map_join(" ", &String.capitalize/1)
end
-
- defp user_attr(nil, _key), do: nil
- defp user_attr(user, key), do: Map.get(user, key)
-
- defp author_label(%Message{metadata: %{"author_name" => name}})
- when is_binary(name) and name != "" do
- name
- end
-
- defp author_label(%Message{author_pubkey: pubkey}) when is_binary(pubkey) and pubkey != "" do
- String.slice(pubkey, 0, 8) <> "…"
- end
-
- defp author_label(_message), do: "Unknown"
-
- defp author_initial(message) do
- message
- |> author_label()
- |> String.first()
- |> Kernel.||("?")
- |> String.upcase()
- end
-
- defp message_timestamp(%Message{inserted_at: %DateTime{} = inserted_at}) do
- DateTime.to_unix(inserted_at, :millisecond)
- end
-
- defp message_timestamp(_message), do: DateTime.utc_now() |> DateTime.to_unix(:millisecond)
end
diff --git a/lib/aether_web/live/chat_panel_component.ex b/lib/aether_web/live/chat_panel_component.ex
new file mode 100644
index 0000000..0e52349
--- /dev/null
+++ b/lib/aether_web/live/chat_panel_component.ex
@@ -0,0 +1,287 @@
+defmodule AetherWeb.ChatPanelComponent do
+ @moduledoc """
+ Reusable chat panel component for standalone and embedded Aether chat surfaces.
+ """
+
+ use Phoenix.LiveComponent
+
+ alias Aether.Chat
+ alias Aether.Chat.{Channel, Message}
+
+ @impl true
+ def update(%{incoming_message: %Message{} = message}, socket) do
+ {:ok, put_message(socket, message)}
+ end
+
+ def update(assigns, socket) do
+ slug = Map.fetch!(assigns, :slug)
+ backend = Map.get(assigns, :backend, :public_sync)
+
+ socket =
+ socket
+ |> assign(assigns)
+ |> assign_new(:channel, fn -> nil end)
+ |> assign_new(:messages, fn -> [] end)
+ |> assign_new(:message_ids, fn -> MapSet.new() end)
+ |> assign_new(:message_count, fn -> 0 end)
+ |> assign_new(:subscribed_channel_id, fn -> nil end)
+
+ socket =
+ if reload_channel?(socket, slug, backend) do
+ load_channel(socket, slug, backend)
+ else
+ socket
+ end
+
+ {:ok, maybe_subscribe(socket)}
+ end
+
+ @impl true
+ def handle_event("send_message", %{"message" => %{"body" => body}}, socket) do
+ body = String.trim(body)
+
+ cond do
+ socket.assigns.channel == nil ->
+ {:noreply, put_flash(socket, :error, "Chat channel is unavailable")}
+
+ socket.assigns.current_user == nil ->
+ {:noreply, put_flash(socket, :error, "Sign in to chat")}
+
+ body == "" ->
+ {:noreply, put_flash(socket, :error, "Message cannot be empty")}
+
+ true ->
+ post_message(socket, body)
+ end
+ end
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+
+
+
+
+ Marmot backend scaffold is enabled for this channel. Browser transport, storage, and signing are not active yet.
+
+
+
+
+ No messages yet. Start the conversation.
+
+
+
+
+ {author_initial(message)}
+
+
+
+ {author_label(message)}
+
+
+
+
{message.body}
+
+
+
+
+ <%= cond do %>
+ <% @backend == :marmot -> %>
+
+ Marmot chat is not enabled in the web UI yet.
+
+ <% @current_user -> %>
+
+ <% true -> %>
+
+ Sign in to join the chat.
+
+ <% end %>
+
+ """
+ end
+
+ defp reload_channel?(socket, slug, backend) do
+ channel = socket.assigns.channel
+ channel == nil || channel.slug != slug || channel.backend != backend
+ end
+
+ defp load_channel(socket, slug, backend) do
+ case Chat.ensure_channel(%{slug: slug, title: channel_title(slug), backend: backend}) do
+ {:ok, %Channel{} = channel} ->
+ messages = load_messages(channel)
+ message_ids = messages |> Enum.map(& &1.id) |> MapSet.new()
+
+ socket
+ |> assign(:channel, channel)
+ |> assign(:backend, channel.backend)
+ |> assign(:messages, messages)
+ |> assign(:message_ids, message_ids)
+ |> assign(:message_count, MapSet.size(message_ids))
+
+ {:error, reason} ->
+ socket
+ |> assign(:channel, nil)
+ |> assign(:backend, backend)
+ |> assign(:messages, [])
+ |> assign(:message_ids, MapSet.new())
+ |> assign(:message_count, 0)
+ |> assign(:chat_error, inspect(reason))
+ end
+ end
+
+ defp maybe_subscribe(
+ %{assigns: %{channel: %Channel{id: id}, subscribed_channel_id: id}} = socket
+ ) do
+ socket
+ end
+
+ defp maybe_subscribe(%{assigns: %{channel: %Channel{} = channel}} = socket) do
+ if connected?(socket) do
+ _ = Chat.subscribe_channel(channel)
+ assign(socket, :subscribed_channel_id, channel.id)
+ else
+ socket
+ end
+ end
+
+ defp maybe_subscribe(socket), do: socket
+
+ defp post_message(socket, body) do
+ user = socket.assigns.current_user
+
+ attrs = %{
+ body: body,
+ author_id: user_attr(user, :id),
+ author_pubkey: user_attr(user, :pubkey_hex) || user_attr(user, :pubkey),
+ metadata: %{"author_name" => user_attr(user, :username)}
+ }
+
+ case Chat.post_message(socket.assigns.channel, attrs) do
+ {:ok, message} ->
+ {:noreply, put_message(socket, message)}
+
+ {:error, reason} ->
+ {:noreply, put_flash(socket, :error, "Failed to send message: #{inspect(reason)}")}
+ end
+ end
+
+ defp put_message(socket, %Message{} = message) do
+ if socket.assigns.channel && message.channel_id == socket.assigns.channel.id do
+ append_message(socket, message)
+ else
+ socket
+ end
+ end
+
+ defp append_message(socket, %Message{} = message) do
+ if MapSet.member?(socket.assigns.message_ids, message.id) do
+ socket
+ else
+ message_ids = MapSet.put(socket.assigns.message_ids, message.id)
+
+ socket
+ |> assign(:messages, socket.assigns.messages ++ [message])
+ |> assign(:message_ids, message_ids)
+ |> assign(:message_count, MapSet.size(message_ids))
+ end
+ end
+
+ defp load_messages(%Channel{} = channel) do
+ case Chat.list_messages(channel) do
+ {:ok, messages} -> messages
+ {:error, _reason} -> []
+ end
+ end
+
+ defp channel_title("general"), do: "General Chat"
+
+ defp channel_title(slug) do
+ slug
+ |> String.replace(["-", "_"], " ")
+ |> String.split(" ", trim: true)
+ |> Enum.map_join(" ", &String.capitalize/1)
+ end
+
+ defp user_attr(nil, _key), do: nil
+ defp user_attr(user, key), do: Map.get(user, key)
+
+ defp author_label(%Message{metadata: %{"author_name" => name}})
+ when is_binary(name) and name != "" do
+ name
+ end
+
+ defp author_label(%Message{author_pubkey: pubkey}) when is_binary(pubkey) and pubkey != "" do
+ String.slice(pubkey, 0, 8) <> "…"
+ end
+
+ defp author_label(_message), do: "Unknown"
+
+ defp author_initial(message) do
+ message
+ |> author_label()
+ |> String.first()
+ |> Kernel.||("?")
+ |> String.upcase()
+ end
+
+ defp message_timestamp(%Message{inserted_at: %DateTime{} = inserted_at}) do
+ DateTime.to_unix(inserted_at, :millisecond)
+ end
+
+ defp message_timestamp(_message), do: DateTime.utc_now() |> DateTime.to_unix(:millisecond)
+end