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.
This commit is contained in:
2026-05-25 12:50:05 +02:00
parent d257221dc8
commit a55bd9612d
2 changed files with 313 additions and 245 deletions
+26 -245
View File
@@ -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"""
<div class="aether aether-chat h-full min-h-[28rem] p-2" id="aether-chat-embed">
<.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}
/>
</div>
"""
@@ -103,169 +53,28 @@ defmodule AetherWeb.ChatLive do
Aether chat
</p>
<h1 class="mt-1 text-3xl font-semibold tracking-tight text-base-content">
{if @channel, do: @channel.title, else: "Chat"}
{channel_title(@chat_request.slug)}
</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-base-content/70">
Public synced group chat. Marmot-backed channels will come later without changing the chat surface.
</p>
</div>
<div class="rounded-full border border-base-300 px-4 py-2 text-sm font-medium text-base-content/70">
{@message_count} messages
</div>
</div>
</section>
<.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}
/>
</div>
</Layouts.app>
"""
end
defp chat_panel(assigns) do
~H"""
<div
id="aether-chat-panel"
class={[
"flex min-h-0 flex-1 flex-col overflow-hidden border border-base-300 bg-base-100 shadow-sm",
@embedded? && "h-full rounded-2xl",
!@embedded? && "rounded-3xl"
]}
>
<div
:if={@embedded?}
id="chat-embed-header"
class="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-3"
>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-base-content/50">Chat</p>
<h2 class="text-base font-semibold text-base-content">{if @channel, do: @channel.title, else: "Chat"}</h2>
</div>
<span class="text-xs text-base-content/60">{@message_count}</span>
</div>
<div
:if={@backend == :marmot}
id="chat-marmot-notice"
class="border-b border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning-content"
>
Marmot backend scaffold is enabled for this channel. Browser transport, storage, and signing are not active yet.
</div>
<div
id="chat-messages"
phx-hook="AetherChatScroll"
phx-update="stream"
class={[
"flex-1 space-y-3 overflow-y-auto p-4",
!@embedded? && "min-h-[24rem] sm:p-6",
@embedded? && "min-h-0"
]}
>
<div
id="chat-empty"
class="hidden only:block rounded-box border border-dashed border-base-300 p-8 text-center text-sm text-base-content/60"
>
No messages yet. Start the conversation.
</div>
<article :for={{id, message} <- @streams.messages} id={id} class="aether-chat-message flex gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{author_initial(message)}
</div>
<div class="max-w-[min(42rem,85%)] rounded-2xl bg-base-200 px-4 py-3 shadow-sm">
<div class="mb-1 flex items-center gap-2 text-xs text-base-content/60">
<span class="font-semibold text-base-content/75">{author_label(message)}</span>
<span
phx-hook="DateTime"
id={"chat-time-#{message.id}"}
phx-update="ignore"
data-timestamp={message_timestamp(message)}
data-relative="true"
data-format="LLL"
>
</span>
</div>
<p class="whitespace-pre-wrap break-words text-sm leading-6 text-base-content">{message.body}</p>
</div>
</article>
</div>
<%= cond do %>
<% @backend == :marmot -> %>
<div id="chat-marmot-disabled" class="border-t border-base-300 bg-base-100 p-5 text-center text-sm text-base-content/70">
Marmot chat is not enabled in the web UI yet.
</div>
<% @current_user -> %>
<form id="chat-message-form" phx-submit="send_message" class="border-t border-base-300 bg-base-100 p-3 sm:p-4">
<div class="flex items-end gap-2 rounded-2xl border border-base-300 bg-base-200/60 p-2">
<label for="chat-message-body" class="sr-only">Message</label>
<textarea
id="chat-message-body"
name="message[body]"
rows="2"
class="textarea textarea-ghost min-h-12 flex-1 resize-none bg-transparent focus:outline-none"
placeholder={"Message #{if @channel, do: @channel.title, else: "chat"}"}
></textarea>
<button id="chat-send-button" type="submit" class="btn btn-primary rounded-full">
Send
</button>
</div>
</form>
<% true -> %>
<div id="chat-signed-out" class="border-t border-base-300 bg-base-100 p-5 text-center text-sm text-base-content/70">
Sign in to join the chat.
</div>
<% end %>
</div>
"""
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
+287
View File
@@ -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"""
<div
id="aether-chat-panel"
class={[
"flex min-h-0 flex-1 flex-col overflow-hidden border border-base-300 bg-base-100 shadow-sm",
@embedded? && "h-full rounded-2xl",
!@embedded? && "rounded-3xl"
]}
>
<div
:if={@embedded?}
id="chat-embed-header"
class="flex items-center justify-between gap-3 border-b border-base-300 px-4 py-3"
>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-base-content/50">Chat</p>
<h2 class="text-base font-semibold text-base-content">{if @channel, do: @channel.title, else: "Chat"}</h2>
</div>
<span class="text-xs text-base-content/60">{@message_count}</span>
</div>
<div
:if={@backend == :marmot}
id="chat-marmot-notice"
class="border-b border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning-content"
>
Marmot backend scaffold is enabled for this channel. Browser transport, storage, and signing are not active yet.
</div>
<div
id="chat-messages"
phx-hook="AetherChatScroll"
class={[
"flex-1 space-y-3 overflow-y-auto p-4",
!@embedded? && "min-h-[24rem] sm:p-6",
@embedded? && "min-h-0"
]}
>
<div
:if={@messages == []}
id="chat-empty"
class="rounded-box border border-dashed border-base-300 p-8 text-center text-sm text-base-content/60"
>
No messages yet. Start the conversation.
</div>
<article :for={message <- @messages} id={"chat-message-#{message.id}"} class="aether-chat-message flex gap-3">
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{author_initial(message)}
</div>
<div class="max-w-[min(42rem,85%)] rounded-2xl bg-base-200 px-4 py-3 shadow-sm">
<div class="mb-1 flex items-center gap-2 text-xs text-base-content/60">
<span class="font-semibold text-base-content/75">{author_label(message)}</span>
<span
phx-hook="DateTime"
id={"chat-time-#{message.id}"}
phx-update="ignore"
data-timestamp={message_timestamp(message)}
data-relative="true"
data-format="LLL"
>
</span>
</div>
<p class="whitespace-pre-wrap break-words text-sm leading-6 text-base-content">{message.body}</p>
</div>
</article>
</div>
<%= cond do %>
<% @backend == :marmot -> %>
<div id="chat-marmot-disabled" class="border-t border-base-300 bg-base-100 p-5 text-center text-sm text-base-content/70">
Marmot chat is not enabled in the web UI yet.
</div>
<% @current_user -> %>
<form id="chat-message-form" phx-submit="send_message" phx-target={@myself} class="border-t border-base-300 bg-base-100 p-3 sm:p-4">
<div class="flex items-end gap-2 rounded-2xl border border-base-300 bg-base-200/60 p-2">
<label for="chat-message-body" class="sr-only">Message</label>
<textarea
id="chat-message-body"
name="message[body]"
rows="2"
class="textarea textarea-ghost min-h-12 flex-1 resize-none bg-transparent focus:outline-none"
placeholder={"Message #{if @channel, do: @channel.title, else: "chat"}"}
></textarea>
<button id="chat-send-button" type="submit" class="btn btn-primary rounded-full">
Send
</button>
</div>
</form>
<% true -> %>
<div id="chat-signed-out" class="border-t border-base-300 bg-base-100 p-5 text-center text-sm text-base-content/70">
Sign in to join the chat.
</div>
<% end %>
</div>
"""
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