defmodule TribeOne.TribesPlugin.AetherWeb.ChatPanelComponent do
@moduledoc """
Reusable chat panel component for standalone and embedded Aether chat surfaces.
"""
use Phoenix.LiveComponent
alias TribeOne.TribesPlugin.Aether.Chat
alias TribeOne.TribesPlugin.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_conversation(%{slug: slug, title: channel_title(slug), backend: backend}) do
{:ok, %Channel{} = channel} ->
messages = load_messages(channel, socket.assigns)
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_conversation(channel, self(), chat_opts(socket.assigns))
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.send_message(socket.assigns.channel, attrs, chat_opts(socket.assigns)) 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, assigns) do
case Chat.list_conversation_messages(channel, chat_opts(assigns)) do
{:ok, messages} -> messages
{:error, _reason} -> []
end
end
defp chat_opts(assigns) do
case Map.get(assigns, :session_privkey) do
privkey when is_binary(privkey) -> [session_privkey: privkey]
_other -> []
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