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""" -
-
-
-

Chat

-

{if @channel, do: @channel.title, else: "Chat"}

-
- {@message_count} -
- -
- Marmot backend scaffold is enabled for this channel. Browser transport, storage, and signing are not active yet. -
- -
- - -
-
- {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""" +
+
+
+

Chat

+

{if @channel, do: @channel.title, else: "Chat"}

+
+ {@message_count} +
+ +
+ 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